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.2 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 All 57 releases
← All changes | includes/seo/class-email-report-generator.php +215 -1 2.1.0 → 2.14.1 View file →
@@ -99,8 +99,16 @@
99 99 private function run(array $config, bool $is_test): array {
100 100 $frequency = (int) ($config['frequency_days'] ?? 30);
101 101
102 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 +
103 111 $shared = $this->data_provider->fetch($frequency);
104 112
105 113 $context = [
106 114 'period_start' => $shared['period_start'] ?? '',
@@ -109,8 +117,22 @@
109 117 'is_test' => $is_test,
110 118 'shared' => $shared,
111 119 ];
112 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 +
113 135 /**
114 136 * Fires before the report is rendered/sent. Pro plugin uses
115 137 * this to fetch + attach the AI Highlights summary.
116 138 *
@@ -156,9 +178,33 @@
156 178 }
157 179
158 180 $html = $this->renderer->render($config, $context);
159 181
160 - $tokens = ['%period%' => $context['period_label']];
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);
161 207 $result = $this->mailer->send($config, $html, $tokens);
162 208
163 209 if (!$is_test) {
164 210 $this->finalize_log($config, $context, $result);
@@ -167,8 +213,9 @@
167 213 $this->config->update_schedule(
168 214 current_time('mysql'),
169 215 $this->compute_next_run($frequency)
170 216 );
217 + $this->config->clear_skip();
171 218 } else {
172 219 // A transient mail failure must not cost the user a whole
173 220 // period, and it must not stamp last_sent_at with a send
174 221 // that never happened. Retry soon; give up after
@@ -202,8 +249,150 @@
202 249 }
203 250 }
204 251
205 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 + /**
206 395 * Claim this period's send by inserting a `pending` row in
207 396 * email_report_logs. The unique key on (site_id, period_start,
208 397 * recipient_hash) is the dedupe gate — a conflicting insert means the
209 398 * period is already accounted for.
@@ -332,8 +521,33 @@
332 521 'log_id' => (int) $existing['id'],
333 522 'attempts' => $attempts,
334 523 'retry' => true,
335 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 + );
336 550 }
337 551
338 552 /**
339 553 * Update the row inserted by record_attempt() with the send outcome.