PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.0.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.0.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.0.0, at includes/seo/class-email-report-generator.php

387 lines 14.7 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 $tokens = ['%period%' => $context['period_label']];
161 $result = $this->mailer->send($config, $html, $tokens);
162
163 if (!$is_test) {
164 $this->finalize_log($config, $context, $result);
165
166 if (!empty($result['success'])) {
167 $this->config->update_schedule(
168 current_time('mysql'),
169 $this->compute_next_run($frequency)
170 );
171 } else {
172 // A transient mail failure must not cost the user a whole
173 // period, and it must not stamp last_sent_at with a send
174 // that never happened. Retry soon; give up after
175 // MAX_SEND_ATTEMPTS and fall back to the normal cadence.
176 $attempts = (int) ($dedupe['attempts'] ?? 1);
177 $next = $attempts >= self::MAX_SEND_ATTEMPTS
178 ? $this->compute_next_run($frequency)
179 : $this->compute_retry_run();
180
181 $this->config->update_schedule(
182 $this->config->get()['last_sent_at'] ?? null,
183 $next
184 );
185 }
186 }
187
188 /**
189 * Fires after a report has been sent (or has failed).
190 *
191 * @since 1.9.0
192 *
193 * @param array $config
194 * @param array $result
195 * @param bool $is_test
196 */
197 do_action('thinkrank_email_report_after_send', $config, $result, $is_test);
198
199 return ['success' => (bool) ($result['success'] ?? false), 'result' => $result];
200 } catch (Throwable $e) {
201 return ['success' => false, 'error' => $e->getMessage()];
202 }
203 }
204
205 /**
206 * Claim this period's send by inserting a `pending` row in
207 * email_report_logs. The unique key on (site_id, period_start,
208 * recipient_hash) is the dedupe gate — a conflicting insert means the
209 * period is already accounted for.
210 *
211 * "Already accounted for" is not always "already delivered", though: a
212 * previous attempt may have failed. In that case we re-claim the same
213 * row for another attempt, up to MAX_SEND_ATTEMPTS, so the retry the
214 * scheduler booked can actually run.
215 *
216 * @return array{inserted:bool,log_id:int,attempts:int,retry?:bool,exhausted?:bool,write_failed?:bool}
217 */
218 private function record_attempt(array $config, array $context): array {
219 global $wpdb;
220 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
221
222 $period_start = $context['period_start'] ?: current_time('mysql');
223 $period_end = $context['period_end'] ?: current_time('mysql');
224 $recipients = (array) ($config['recipients'] ?? []);
225 $hash = $this->recipient_hash($recipients);
226 $site_id = get_current_blog_id();
227
228 // The UNIQUE KEY on (site_id, period_start, recipient_hash) is the
229 // dedupe gate, so a colliding insert is an expected outcome on a
230 // normal tick — not an error. Suppress $wpdb's own error handling
231 // for the duration so a routine dedupe doesn't dump SQL and a stack
232 // trace into the log (or, under WP_DEBUG_DISPLAY, into cron output).
233 $suppressed = $wpdb->suppress_errors(true);
234 $rows = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
235 $table,
236 [
237 'site_id' => $site_id,
238 'period_start' => $period_start,
239 'period_end' => $period_end,
240 'recipient_hash' => $hash,
241 'recipient_count' => count($recipients),
242 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
243 'status' => 'pending',
244 'attempts' => 1,
245 'created_at' => current_time('mysql'),
246 ],
247 ['%d','%s','%s','%s','%d','%d','%s','%d','%s']
248 );
249 $last_error = (string) $wpdb->last_error;
250 $wpdb->suppress_errors($suppressed);
251
252 if ($rows) {
253 return [
254 'inserted' => true,
255 'log_id' => (int) $wpdb->insert_id,
256 'attempts' => 1,
257 ];
258 }
259
260 // A failed insert is only a dedupe signal when it failed *because of
261 // the unique key*. Anything else — missing table, wrong schema, disk
262 // full — must not masquerade as "already sent this period", or every
263 // scheduled send would be silently skipped forever with no alert.
264 if (stripos($last_error, 'duplicate entry') === false) {
265 error_log( // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
266 'ThinkRank email report: could not write the send log — '
267 . ($last_error !== '' ? $last_error : 'insert failed with no error reported.')
268 );
269 return ['inserted' => false, 'log_id' => 0, 'attempts' => 0, 'write_failed' => true];
270 }
271
272 return $this->claim_retry($table, $site_id, $period_start, $hash);
273 }
274
275 /**
276 * A row already exists for this period + recipient set. Decide whether
277 * it represents a completed send (skip) or a failed one we may retry.
278 *
279 * @return array{inserted:bool,log_id:int,attempts:int,retry?:bool,exhausted?:bool}
280 */
281 private function claim_retry(string $table, int $site_id, string $period_start, string $hash): array {
282 global $wpdb;
283
284 $existing = $wpdb->get_row( // phpcs:ignore WordPress.DB.DirectDatabaseQuery, WordPress.DB.PreparedSQL.NotPrepared
285 $wpdb->prepare(
286 // phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- table name is built from $wpdb->prefix.
287 "SELECT id, status, attempts FROM {$table} WHERE site_id = %d AND period_start = %s AND recipient_hash = %s",
288 $site_id,
289 $period_start,
290 $hash
291 ),
292 ARRAY_A
293 );
294
295 // No row behind the failed insert — the write itself broke, not a
296 // dedupe collision. Treat as "don't send" and leave it to the caller.
297 if (!is_array($existing)) {
298 return ['inserted' => false, 'log_id' => 0, 'attempts' => 0];
299 }
300
301 // Anything that isn't a recorded failure means this period is done
302 // (or in flight elsewhere) — the original dedupe behaviour.
303 if (($existing['status'] ?? '') !== 'failed') {
304 return ['inserted' => false, 'log_id' => (int) $existing['id'], 'attempts' => (int) $existing['attempts']];
305 }
306
307 $attempts = (int) ($existing['attempts'] ?? 1);
308 if ($attempts >= self::MAX_SEND_ATTEMPTS) {
309 return [
310 'inserted' => false,
311 'log_id' => (int) $existing['id'],
312 'attempts' => $attempts,
313 'exhausted' => true,
314 ];
315 }
316
317 $attempts++;
318 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
319 $table,
320 [
321 'status' => 'pending',
322 'attempts' => $attempts,
323 'error_message' => null,
324 ],
325 ['id' => (int) $existing['id']],
326 ['%s','%d','%s'],
327 ['%d']
328 );
329
330 return [
331 'inserted' => true,
332 'log_id' => (int) $existing['id'],
333 'attempts' => $attempts,
334 'retry' => true,
335 ];
336 }
337
338 /**
339 * Update the row inserted by record_attempt() with the send outcome.
340 */
341 private function finalize_log(array $config, array $context, array $result): void {
342 global $wpdb;
343 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
344
345 $success = !empty($result['success']);
346 $status = $success ? 'sent' : 'failed';
347 $error = $success ? null : ($result['error'] ?? __('Unknown send failure.', 'thinkrank'));
348
349 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
350 $table,
351 [
352 'status' => $status,
353 // Only a real send has a send time. wpdb writes a literal
354 // NULL for a null value, which is what a failed row wants.
355 'sent_at' => $success ? current_time('mysql') : null,
356 'error_message' => $error,
357 ],
358 [
359 'site_id' => get_current_blog_id(),
360 'period_start' => $context['period_start'] ?: current_time('mysql'),
361 'recipient_hash' => $this->recipient_hash((array) $config['recipients']),
362 ],
363 ['%s','%s','%s'],
364 ['%d','%s','%s']
365 );
366 }
367
368 /**
369 * Stable hash of the recipient list. Lowercased + sorted so reordering
370 * doesn't bypass dedupe.
371 */
372 private function recipient_hash(array $recipients): string {
373 $normalized = array_values(array_unique(array_map('strtolower', array_map('trim', $recipients))));
374 sort($normalized);
375 return hash('sha256', implode(',', $normalized));
376 }
377
378 private function compute_next_run(int $frequency_days): string {
379 $frequency_days = max(1, $frequency_days);
380 return wp_date('Y-m-d H:i:s', strtotime('+' . $frequency_days . ' days'));
381 }
382
383 private function compute_retry_run(): string {
384 return wp_date('Y-m-d H:i:s', strtotime('+' . self::RETRY_DELAY_HOURS . ' hours'));
385 }
386 }
387