PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.9.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.9.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 1.0.2 All 50 releases
← All changes | includes/seo/class-email-report-generator.php +289 -19 1.28.0 → 2.9.0 View file →
@@ -31,8 +31,22 @@
31 31 * @since 1.9.0
32 32 */
33 33 final class Email_Report_Generator {
34 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 +
35 49 private Email_Report_Config $config;
36 50 private Email_Report_Renderer $renderer;
37 51 private Email_Report_Mailer $mailer;
38 52 private Email_Report_Data_Provider $data_provider;
@@ -85,8 +99,16 @@
85 99 private function run(array $config, bool $is_test): array {
86 100 $frequency = (int) ($config['frequency_days'] ?? 30);
87 101
88 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 +
89 111 $shared = $this->data_provider->fetch($frequency);
90 112
91 113 $context = [
92 114 'period_start' => $shared['period_start'] ?? '',
@@ -106,27 +128,95 @@
106 128 * @param array $context Render context with shared data.
107 129 */
108 130 do_action('thinkrank_email_report_before_generate', $config, $context);
109 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 +
110 146 // Dedupe check for scheduled sends only (tests can repeat).
111 147 if (!$is_test) {
112 148 $dedupe = $this->record_attempt($config, $context);
113 149 if (!$dedupe['inserted']) {
114 - return ['success' => false, 'skipped' => 'duplicate'];
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 + ];
115 163 }
116 164 }
117 165
118 166 $html = $this->renderer->render($config, $context);
119 167
120 - $tokens = ['%period%' => $context['period_label']];
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);
121 193 $result = $this->mailer->send($config, $html, $tokens);
122 194
123 195 if (!$is_test) {
124 196 $this->finalize_log($config, $context, $result);
125 - $this->config->update_schedule(
126 - current_time('mysql'),
127 - $this->compute_next_run($frequency)
128 - );
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 + }
129 219 }
130 220
131 221 /**
132 222 * Fires after a report has been sent (or has failed).
@@ -145,12 +235,70 @@
145 235 }
146 236 }
147 237
148 238 /**
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.
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}
152 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 + */
153 301 private function record_attempt(array $config, array $context): array {
154 302 global $wpdb;
155 303 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
156 304
@@ -156,32 +304,147 @@
156 304
157 305 $period_start = $context['period_start'] ?: current_time('mysql');
158 306 $period_end = $context['period_end'] ?: current_time('mysql');
159 307 $recipients = (array) ($config['recipients'] ?? []);
308 + $hash = $this->recipient_hash($recipients);
309 + $site_id = get_current_blog_id();
160 310
161 - // Insert with dbDelta-friendly columns; the unique key handles dedupe.
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);
162 317 $rows = $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
163 318 $table,
164 319 [
165 - 'site_id' => get_current_blog_id(),
320 + 'site_id' => $site_id,
166 321 'period_start' => $period_start,
167 322 'period_end' => $period_end,
168 - 'recipient_hash' => $this->recipient_hash($recipients),
323 + 'recipient_hash' => $hash,
169 324 'recipient_count' => count($recipients),
170 325 'frequency_days' => (int) ($config['frequency_days'] ?? 30),
171 326 'status' => 'pending',
327 + 'attempts' => 1,
172 328 'created_at' => current_time('mysql'),
173 329 ],
174 - ['%d','%s','%s','%s','%d','%d','%s','%s']
330 + ['%d','%s','%s','%s','%d','%d','%s','%d','%s']
175 331 );
332 + $last_error = (string) $wpdb->last_error;
333 + $wpdb->suppress_errors($suppressed);
176 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 +
177 413 return [
178 - 'inserted' => (bool) $rows,
179 - 'log_id' => (int) $wpdb->insert_id,
414 + 'inserted' => true,
415 + 'log_id' => (int) $existing['id'],
416 + 'attempts' => $attempts,
417 + 'retry' => true,
180 418 ];
181 419 }
182 420
183 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 + /**
184 447 * Update the row inserted by record_attempt() with the send outcome.
185 448 */
186 449 private function finalize_log(array $config, array $context, array $result): void {
187 450 global $wpdb;
@@ -186,16 +449,19 @@
186 449 private function finalize_log(array $config, array $context, array $result): void {
187 450 global $wpdb;
188 451 $table = $wpdb->prefix . 'thinkrank_email_report_logs';
189 452
190 - $status = !empty($result['success']) ? 'sent' : 'failed';
191 - $error = empty($result['success']) ? ($result['error'] ?? __('Unknown send failure.', 'thinkrank')) : null;
453 + $success = !empty($result['success']);
454 + $status = $success ? 'sent' : 'failed';
455 + $error = $success ? null : ($result['error'] ?? __('Unknown send failure.', 'thinkrank'));
192 456
193 457 $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery
194 458 $table,
195 459 [
196 - 'status' => $status,
197 - 'sent_at' => current_time('mysql'),
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,
198 464 'error_message' => $error,
199 465 ],
200 466 [
201 467 'site_id' => get_current_blog_id(),
@@ -219,6 +485,10 @@
219 485
220 486 private function compute_next_run(int $frequency_days): string {
221 487 $frequency_days = max(1, $frequency_days);
222 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'));
223 493 }
224 494 }