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

225 lines 7.5 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 private Email_Report_Config $config;
36 private Email_Report_Renderer $renderer;
37 private Email_Report_Mailer $mailer;
38 private Email_Report_Data_Provider $data_provider;
39
40 public function __construct(
41 Email_Report_Config $config,
42 Email_Report_Renderer $renderer,
43 Email_Report_Mailer $mailer,
44 Email_Report_Data_Provider $data_provider
45 ) {
46 $this->config = $config;
47 $this->renderer = $renderer;
48 $this->mailer = $mailer;
49 $this->data_provider = $data_provider;
50 }
51
52 /**
53 * Run the scheduled-send path.
54 *
55 * @return array{success:bool,skipped?:string,result?:array,error?:string}
56 */
57 public function generate_for_due(): array {
58 $config = $this->config->get();
59
60 if (empty($config['enabled'])) {
61 return ['success' => false, 'skipped' => 'disabled'];
62 }
63 if (empty($config['recipients'])) {
64 return ['success' => false, 'skipped' => 'no_recipients'];
65 }
66
67 return $this->run($config, false);
68 }
69
70 /**
71 * Run the immediate "send test" path. Bypasses the schedule check
72 * and the dedupe log insert (a test isn't a real period send).
73 */
74 public function generate_test(): array {
75 $config = $this->config->get();
76 if (empty($config['recipients'])) {
77 return ['success' => false, 'error' => __('No recipients configured.', 'thinkrank')];
78 }
79 return $this->run($config, true);
80 }
81
82 /**
83 * Common send pipeline.
84 */
85 private function run(array $config, bool $is_test): array {
86 $frequency = (int) ($config['frequency_days'] ?? 30);
87
88 try {
89 $shared = $this->data_provider->fetch($frequency);
90
91 $context = [
92 'period_start' => $shared['period_start'] ?? '',
93 'period_end' => $shared['period_end'] ?? '',
94 'period_label' => $shared['period_label'] ?? '',
95 'is_test' => $is_test,
96 'shared' => $shared,
97 ];
98
99 /**
100 * Fires before the report is rendered/sent. Pro plugin uses
101 * this to fetch + attach the AI Highlights summary.
102 *
103 * @since 1.9.0
104 *
105 * @param array $config Per-site config.
106 * @param array $context Render context with shared data.
107 */
108 do_action('thinkrank_email_report_before_generate', $config, $context);
109
110 // Dedupe check for scheduled sends only (tests can repeat).
111 if (!$is_test) {
112 $dedupe = $this->record_attempt($config, $context);
113 if (!$dedupe['inserted']) {
114 return ['success' => false, 'skipped' => 'duplicate'];
115 }
116 }
117
118 $html = $this->renderer->render($config, $context);
119
120 $tokens = ['%period%' => $context['period_label']];
121 $result = $this->mailer->send($config, $html, $tokens);
122
123 if (!$is_test) {
124 $this->finalize_log($config, $context, $result);
125 $this->config->update_schedule(
126 current_time('mysql'),
127 $this->compute_next_run($frequency)
128 );
129 }
130
131 /**
132 * Fires after a report has been sent (or has failed).
133 *
134 * @since 1.9.0
135 *
136 * @param array $config
137 * @param array $result
138 * @param bool $is_test
139 */
140 do_action('thinkrank_email_report_after_send', $config, $result, $is_test);
141
142 return ['success' => (bool) ($result['success'] ?? false), 'result' => $result];
143 } catch (Throwable $e) {
144 return ['success' => false, 'error' => $e->getMessage()];
145 }
146 }
147
148 /**
149 * Insert a `pending` row in email_report_logs. The unique key on
150 * (site_id, period_start, recipient_hash) is the dedupe gate — a
151 * conflicting insert returns 0 rows and we abort the send.
152 */
153 private function record_attempt(array $config, array $context): array {
154 global $wpdb;
155 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
156
157 $period_start = $context['period_start'] ?: current_time('mysql');
158 $period_end = $context['period_end'] ?: current_time('mysql');
159 $recipients = (array) ($config['recipients'] ?? []);
160
161 // Insert with dbDelta-friendly columns; the unique key handles dedupe.
162 $rows = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
163 $table,
164 [
165 'site_id' => get_current_blog_id(),
166 'period_start' => $period_start,
167 'period_end' => $period_end,
168 'recipient_hash' => $this->recipient_hash($recipients),
169 'recipient_count' => count($recipients),
170 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
171 'status' => 'pending',
172 'created_at' => current_time('mysql'),
173 ],
174 ['%d','%s','%s','%s','%d','%d','%s','%s']
175 );
176
177 return [
178 'inserted' => (bool) $rows,
179 'log_id' => (int) $wpdb->insert_id,
180 ];
181 }
182
183 /**
184 * Update the row inserted by record_attempt() with the send outcome.
185 */
186 private function finalize_log(array $config, array $context, array $result): void {
187 global $wpdb;
188 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
189
190 $status = !empty($result['success']) ? 'sent' : 'failed';
191 $error = empty($result['success']) ? ($result['error'] ?? __('Unknown send failure.', 'thinkrank')) : null;
192
193 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
194 $table,
195 [
196 'status' => $status,
197 'sent_at' => current_time('mysql'),
198 'error_message' => $error,
199 ],
200 [
201 'site_id' => get_current_blog_id(),
202 'period_start' => $context['period_start'] ?: current_time('mysql'),
203 'recipient_hash' => $this->recipient_hash((array) $config['recipients']),
204 ],
205 ['%s','%s','%s'],
206 ['%d','%s','%s']
207 );
208 }
209
210 /**
211 * Stable hash of the recipient list. Lowercased + sorted so reordering
212 * doesn't bypass dedupe.
213 */
214 private function recipient_hash(array $recipients): string {
215 $normalized = array_values(array_unique(array_map('strtolower', array_map('trim', $recipients))));
216 sort($normalized);
217 return hash('sha256', implode(',', $normalized));
218 }
219
220 private function compute_next_run(int $frequency_days): string {
221 $frequency_days = max(1, $frequency_days);
222 return wp_date('Y-m-d H:i:s', strtotime('+' . $frequency_days . ' days'));
223 }
224 }
225