PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.8.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.8.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 1.1.0 All 49 releases
← All changes | includes/seo/class-email-report-config.php +157 -290 2.4.02.8.0 View file →
@@ -1,18 +1,19 @@
1 1 <?php
2 2 /**
3 3 * Email Report Config
4 4 *
5 - * Persistence layer for the per-site Email Reporting settings. Stores a
6 - * single associative array under the `thinkrank_email_report_config` option
7 - * — one row per site is enough; we don't shard by user. Values are
8 - * sanitized at the boundary and capability-clamped via Plan_Config so a
9 - * free plan never accidentally persists Pro values that don't belong.
5 + * Persistence layer for the per-site Email Reporting state. Stores a single
6 + * associative array under the `thinkrank_email_report_config` option.
10 7 *
11 - * Pro plugin can extend the saved schema by hooking
12 - * `thinkrank_email_report_config_schema` (added fields are sanitized
13 - * if a callback is provided).
8 + * The free report is fixed: every 30 days, to the site admin email, with every
9 + * section. What is stored is only whether it is on and when it last and next
10 + * runs. ThinkRank Pro owns the schedule, recipient and branding settings and
11 + * supplies them through the `thinkrank_email_report_config` filter (#673).
14 12 *
13 + * Keys a save does not own are left in the stored array untouched, so values an
14 + * earlier release wrote there (recipients, branding) survive for Pro to pick up.
15 + *
15 16 * @package ThinkRank
16 17 * @subpackage SEO
17 18 * @since 1.9.0
18 19 */
@@ -20,10 +21,8 @@
20 21 declare(strict_types=1);
21 22
22 23 namespace ThinkRank\SEO;
23 24
24 -use ThinkRank\Core\Plan_Config;
25 -
26 25 if (!defined('ABSPATH')) {
27 26 exit;
28 27 }
29 28
@@ -36,264 +35,206 @@
36 35
37 36 private const OPTION_KEY = 'thinkrank_email_report_config';
38 37
39 38 /**
40 - * Load defaults helper. Lazy-loads the config defaults file because
41 - * it lives outside the autoloader path (it's procedural functions).
39 + * Days between reports when nothing filters the schedule.
42 40 */
43 - private function defaults(): array {
41 + public const FREQUENCY_DAYS = 30;
42 +
43 + /**
44 + * Load the procedural defaults file, which lives outside the autoloader.
45 + */
46 + private function load_defaults_file(): void {
44 47 if (!function_exists('thinkrank_get_default_email_report_config')) {
45 48 require_once THINKRANK_PLUGIN_DIR . 'includes/config/email-report-settings-config.php';
46 49 }
47 - return thinkrank_get_default_email_report_config();
48 50 }
49 51
50 52 /**
51 - * Read the current config. Always merges over defaults so newly added
52 - * keys (e.g. after a plugin update) are populated even on existing sites.
53 + * The stored option, as an array.
53 54 */
54 - public function get(): array {
55 + private function stored(): array {
55 56 $stored = get_option(self::OPTION_KEY, []);
56 - if (!is_array($stored)) {
57 - $stored = [];
58 - }
59 - return array_merge($this->defaults(), $stored);
57 +
58 + return is_array($stored) ? $stored : [];
60 59 }
61 60
62 61 /**
63 - * Save the config. Returns the post-sanitize array that was persisted
64 - * so callers can echo it back to the client and avoid a second read.
62 + * The resolved config every consumer reads.
65 63 *
66 - * Sanitization happens here, not in the REST args layer — the REST
67 - * layer accepts intent, this layer enforces invariants. That way the
68 - * cron-driven path (which doesn't go through REST) gets the same guarantees.
64 + * @return array{enabled: bool, frequency_days: int, recipients: string[], sections_enabled: string[], next_scheduled_at: ?string, last_sent_at: ?string, last_skip: ?array}
69 65 */
70 - public function save(array $input): array {
71 - $sanitized = $this->sanitize($input);
66 + public function get(): array {
67 + $this->load_defaults_file();
72 68
73 - // Defense-in-depth: re-clamp at save time even though sanitize() also clamps.
74 - $sanitized['frequency_days'] = Plan_Config::clamp_email_report_frequency((int) $sanitized['frequency_days']);
75 - $sanitized['recipients'] = Plan_Config::clamp_email_report_recipients($sanitized['recipients']);
69 + $stored = $this->stored();
70 + $state = [
71 + 'enabled' => !empty($stored['enabled']),
72 + 'next_scheduled_at' => $stored['next_scheduled_at'] ?? null,
73 + 'last_sent_at' => $stored['last_sent_at'] ?? null,
74 + // Why the last scheduled run sent nothing, or null. Read by the
75 + // panel so a paused report is never mistaken for a healthy one.
76 + 'last_skip' => is_array($stored['last_skip'] ?? null) ? $stored['last_skip'] : null,
77 + ];
76 78
77 - $previous = get_option(self::OPTION_KEY, []);
78 - $previous = is_array($previous) ? $previous : [];
79 - $frequency_changed = isset($previous['frequency_days'])
80 - && (int) $previous['frequency_days'] !== (int) $sanitized['frequency_days'];
79 + $report = [
80 + 'frequency_days' => self::FREQUENCY_DAYS,
81 + 'recipients' => [(string) get_option('admin_email')],
82 + 'sections_enabled' => array_keys(thinkrank_get_email_report_default_sections()),
83 + ];
81 84
82 - // Seed next_scheduled_at on first enable so the UI shows a real
83 - // "Next report" date immediately. The scheduler still re-seeds on
84 - // its first tick for any other path that flips enable on.
85 - //
86 - // A frequency change also has to move the date: carrying the old
87 - // timestamp through meant a user switching 30 → 7 days still waited
88 - // out the original 30-day window before the new cadence took effect.
89 - if ($sanitized['enabled'] && (empty($sanitized['next_scheduled_at']) || $frequency_changed)) {
90 - // Anchor off the last send when we have one, so shortening the
91 - // cadence brings the next report forward instead of adding a
92 - // fresh full period on top of time already elapsed.
93 - // last_sent_at is a site-local wall clock (current_time('mysql')).
94 - // strtotime() would read it as UTC and skew the whole cadence by
95 - // the site's offset, so resolve it in the site timezone instead.
96 - $anchor = $frequency_changed && !empty($sanitized['last_sent_at'])
97 - ? (int) get_gmt_from_date((string) $sanitized['last_sent_at'], 'U')
98 - : time();
99 - $anchor = $anchor ?: time();
100 -
101 - $next = strtotime('+' . max(1, (int) $sanitized['frequency_days']) . ' days', $anchor);
102 -
103 - // Never schedule into the past — a big cadence cut on an old
104 - // last_sent_at means "due now", which the next tick picks up.
105 - $sanitized['next_scheduled_at'] = wp_date(
106 - 'Y-m-d H:i:s',
107 - max($next ?: time(), time())
108 - );
109 - }
110 -
111 - update_option(self::OPTION_KEY, $sanitized, false);
112 -
113 85 /**
114 - * Fires after Email Report config is saved.
86 + * Filter what the report covers and who receives it.
115 87 *
116 - * Pro plugin uses this to re-validate its own added fields, refresh
117 - * an audit table, or trigger a re-schedule.
88 + * ThinkRank Pro returns its own schedule, recipients and sections here.
89 + * Extra keys are passed through to the renderer and mailer filters.
90 + * The on/off switch and schedule timestamps are not filterable.
118 91 *
119 - * @since 1.9.0
92 + * @since 2.6.0
120 93 *
121 - * @param array $sanitized The persisted config.
94 + * @param array $report {
95 + * @type int $frequency_days Days between reports.
96 + * @type string[] $recipients Recipient addresses.
97 + * @type string[] $sections_enabled Section keys, in render order.
98 + * }
99 + * @param array $state Stored on/off switch and schedule timestamps.
122 100 */
123 - do_action('thinkrank_email_report_settings_saved', $sanitized);
101 + $filtered = apply_filters('thinkrank_email_report_config', $report, $state);
102 + $filtered = is_array($filtered) ? $filtered : $report;
124 103
125 - return $sanitized;
104 + return array_merge(
105 + $filtered,
106 + [
107 + 'frequency_days' => max(1, (int) ($filtered['frequency_days'] ?? self::FREQUENCY_DAYS)),
108 + 'recipients' => $this->normalize_recipients($filtered['recipients'] ?? []),
109 + 'sections_enabled' => $this->normalize_section_keys($filtered['sections_enabled'] ?? []),
110 + ],
111 + $state
112 + );
126 113 }
127 114
128 115 /**
129 - * Pure sanitization — no DB writes. Useful for previews and tests.
116 + * Save the on/off switch. Returns the resolved config after the write.
130 117 *
131 - * Free vs. Pro behavior: Pro-only fields are accepted into the array
132 - * even on free, but their values are coerced to defaults if the user
133 - * isn't allowed to set them. Why keep them at all? So if the user
134 - * upgrades, their previously-saved values aren't lost.
118 + * The first enable seeds `next_scheduled_at` so the UI shows a real "Next
119 + * report" date immediately. The scheduler still re-seeds on its first tick
120 + * for any other path that flips enable on.
135 121 */
136 - public function sanitize(array $input): array {
137 - $defaults = $this->defaults();
138 - $caps = Plan_Config::email_report();
139 - $limits = function_exists('thinkrank_get_email_report_field_limits')
140 - ? thinkrank_get_email_report_field_limits()
141 - : [];
122 + public function save(array $input): array {
123 + $this->load_defaults_file();
142 124
143 - // Existing stored values are the baseline — partial updates (e.g.
144 - // a toggle-only POST or a Pro field added later) merge over the
145 - // saved config rather than reverting unsupplied keys to defaults.
146 - $stored = get_option(self::OPTION_KEY, []);
147 - if (!is_array($stored)) {
148 - $stored = [];
125 + $stored = $this->stored() + thinkrank_get_default_email_report_config();
126 +
127 + if (array_key_exists('enabled', $input)) {
128 + $stored['enabled'] = (bool) filter_var($input['enabled'], FILTER_VALIDATE_BOOLEAN);
149 129 }
150 - $existing = array_merge($defaults, $stored);
151 130
152 - $clean = [];
131 + if ($stored['enabled'] && empty($stored['next_scheduled_at'])) {
132 + $next = strtotime('+' . $this->get()['frequency_days'] . ' days');
153 133
154 - $clean['enabled'] = isset($input['enabled'])
155 - ? !empty($input['enabled'])
156 - : (bool) $existing['enabled'];
134 + $stored['next_scheduled_at'] = wp_date('Y-m-d H:i:s', max($next ?: time(), time()));
135 + }
157 136
158 - $clean['frequency_days'] = Plan_Config::clamp_email_report_frequency(
159 - isset($input['frequency_days']) ? (int) $input['frequency_days'] : (int) $existing['frequency_days']
160 - );
137 + update_option(self::OPTION_KEY, $stored, false);
161 138
162 - $clean['recipients'] = Plan_Config::clamp_email_report_recipients(
163 - $this->normalize_recipients($input['recipients'] ?? $existing['recipients'])
164 - );
139 + $config = $this->get();
165 140
166 - // Subject: free plan always uses the default. Pro: keep existing
167 - // when input doesn't include the key, accept new when it does.
168 - //
169 - // The subject carries variable tags (%site_title%, %date%, %period%),
170 - // so it is sanitized as a template: sanitize_text_field() reads %date%
171 - // as percent-encoding and stores "te%" (#521). intro_text/footer_text
172 - // take the same tags but go through wp_kses_post(), which leaves them
173 - // alone, and header_background holds a colour rather than a template.
174 - if (!empty($caps['custom_subject']) && array_key_exists('subject_template', $input)) {
175 - $subject = Pattern_Resolver::sanitize_template((string) $input['subject_template']);
176 - if ($subject === '') {
177 - $subject = (string) $defaults['subject_template'];
178 - }
179 - } elseif (!empty($caps['custom_subject'])) {
180 - $subject = (string) $existing['subject_template'];
181 - } else {
182 - $subject = (string) $defaults['subject_template'];
183 - }
184 - $clean['subject_template'] = $this->trim_to($subject, $limits['subject_template'] ?? 200);
141 + /**
142 + * Fires after Email Report config is saved.
143 + *
144 + * @since 1.9.0
145 + *
146 + * @param array $config The resolved config.
147 + */
148 + do_action('thinkrank_email_report_settings_saved', $config);
185 149
186 - // Logo URL: free plan stays null. Pro: only overwrite when the key
187 - // is present in input (so partial updates don't blank the logo).
188 - $clean['logo_url'] = $this->resolve_optional_url(
189 - $caps,
190 - 'custom_logo',
191 - $input,
192 - 'logo_url',
193 - $existing['logo_url'] ?? null,
194 - $limits['logo_url'] ?? 2048
195 - );
150 + return $config;
151 + }
196 152
197 - $clean['logo_link'] = $this->resolve_optional_url(
198 - $caps,
199 - 'logo_link',
200 - $input,
201 - 'logo_link',
202 - $existing['logo_link'] ?? null,
203 - $limits['logo_link'] ?? 2048
204 - );
153 + /**
154 + * Move the next send after the report's frequency changed.
155 + *
156 + * Called by whatever changed the frequency (ThinkRank Pro) with the value
157 + * it had before. Carrying the old timestamp through meant switching 30 → 7
158 + * days still waited out the original 30-day window. The new date anchors
159 + * off the last send when there is one, so shortening the cadence brings the
160 + * next report forward instead of adding a full period on top of time
161 + * already elapsed.
162 + *
163 + * @param int $previous_frequency_days Frequency before the change.
164 + * @return array The resolved config.
165 + */
166 + public function reschedule(int $previous_frequency_days): array {
167 + $config = $this->get();
205 168
206 - $clean['header_background'] = $this->resolve_optional_text(
207 - $caps,
208 - 'header_background',
209 - $input,
210 - 'header_background',
211 - $existing['header_background'] ?? null,
212 - $limits['header_background'] ?? 500
213 - );
169 + if (empty($config['enabled']) || $previous_frequency_days === (int) $config['frequency_days']) {
170 + return $config;
171 + }
214 172
215 - // Free is forced to the default toggle (true) so the dashboard CTA
216 - // still appears. Pro: prefer input, fall back to existing, then default.
217 - $clean['link_to_full_report'] = empty($caps['link_to_full_report'])
218 - ? (bool) $defaults['link_to_full_report']
219 - : (
220 - array_key_exists('link_to_full_report', $input)
221 - ? (bool) $input['link_to_full_report']
222 - : (bool) $existing['link_to_full_report']
223 - );
173 + // last_sent_at is a site-local wall clock (current_time('mysql')).
174 + // strtotime() would read it as UTC and skew the whole cadence by the
175 + // site's offset, so resolve it in the site timezone instead.
176 + $anchor = !empty($config['last_sent_at'])
177 + ? (int) get_gmt_from_date((string) $config['last_sent_at'], 'U')
178 + : time();
179 + $anchor = $anchor ?: time();
224 180
225 - $clean['intro_text'] = $this->resolve_optional_rich_text(
226 - $caps,
227 - 'intro_text',
228 - $input,
229 - 'intro_text',
230 - $existing['intro_text'] ?? null,
231 - $limits['intro_text'] ?? 5000
232 - );
181 + $next = strtotime('+' . (int) $config['frequency_days'] . ' days', $anchor);
233 182
234 - $clean['footer_text'] = $this->resolve_optional_rich_text(
235 - $caps,
236 - 'footer_text',
237 - $input,
238 - 'footer_text',
239 - $existing['footer_text'] ?? null,
240 - $limits['footer_text'] ?? 5000
183 + // Never schedule into the past — a big cadence cut on an old
184 + // last_sent_at means "due now", which the next tick picks up.
185 + return $this->update_schedule(
186 + $config['last_sent_at'],
187 + wp_date('Y-m-d H:i:s', max($next ?: time(), time()))
241 188 );
189 + }
242 190
243 - $clean['additional_css'] = empty($caps['additional_css'])
244 - ? null
245 - : (
246 - array_key_exists('additional_css', $input)
247 - ? $this->sanitize_css($input['additional_css'], $limits['additional_css'] ?? 20000)
248 - : ($existing['additional_css'] ?? null)
249 - );
191 + /**
192 + * Note why a scheduled run sent nothing (#742).
193 + *
194 + * `search_console_not_connected` leaves the schedule alone so the next
195 + * hourly tick tries again; `no_data` is recorded by the generator after
196 + * it has already pushed the schedule out a period. Either way the panel
197 + * shows the reason and when it was last seen.
198 + *
199 + * @param string $reason Machine-readable reason.
200 + */
201 + public function record_skip(string $reason): void {
202 + $stored = $this->stored();
203 + $stored['last_skip'] = [
204 + 'reason' => sanitize_key($reason),
205 + 'at' => current_time('mysql'),
206 + ];
207 + update_option(self::OPTION_KEY, $stored, false);
208 + }
250 209
251 - // Sections: Free is locked to all-on. Pro user submits the list,
252 - // falling back to existing when the key is missing.
253 - if (empty($caps['sections_configurable'])) {
254 - $clean['sections_enabled'] = (array) $defaults['sections_enabled'];
255 - } elseif (array_key_exists('sections_enabled', $input)) {
256 - $clean['sections_enabled'] = $this->sanitize_section_keys($input['sections_enabled']);
257 - } else {
258 - $clean['sections_enabled'] = (array) $existing['sections_enabled'];
210 + /**
211 + * A report went out — whatever paused it earlier no longer applies.
212 + */
213 + public function clear_skip(): void {
214 + $stored = $this->stored();
215 + if (!array_key_exists('last_skip', $stored)) {
216 + return;
259 217 }
260 -
261 - // Schedule timestamps are server-managed — never trust client input.
262 - $clean['next_scheduled_at'] = $stored['next_scheduled_at'] ?? null;
263 - $clean['last_sent_at'] = $stored['last_sent_at'] ?? null;
264 -
265 - /**
266 - * Filter the sanitized config before persistence.
267 - *
268 - * Pro plugin uses this to sanitize fields it has added via
269 - * `thinkrank_email_report_config_schema`. The filter receives the
270 - * raw input alongside the sanitized output so consumers can read
271 - * pro-only field intent without re-parsing the request.
272 - *
273 - * @since 1.9.0
274 - *
275 - * @param array $clean Sanitized config so far.
276 - * @param array $input Raw input as received.
277 - */
278 - return apply_filters('thinkrank_email_report_config_sanitized', $clean, $input);
218 + unset($stored['last_skip']);
219 + update_option(self::OPTION_KEY, $stored, false);
279 220 }
280 221
281 222 /**
282 223 * Update only the schedule timestamps. Called from the scheduler after
283 - * a successful send so we don't round-trip the whole sanitize() flow
284 - * (the rest of the config hasn't changed).
224 + * a send.
285 225 */
286 226 public function update_schedule(?string $last_sent_at, ?string $next_scheduled_at): array {
287 - $current = $this->get();
288 - $current['last_sent_at'] = $last_sent_at;
289 - $current['next_scheduled_at'] = $next_scheduled_at;
290 - update_option(self::OPTION_KEY, $current, false);
291 - return $current;
227 + $stored = $this->stored();
228 + $stored['last_sent_at'] = $last_sent_at;
229 + $stored['next_scheduled_at'] = $next_scheduled_at;
230 + update_option(self::OPTION_KEY, $stored, false);
231 +
232 + return $this->get();
292 233 }
293 234
294 235 /**
295 - * Normalize a recipient input that might arrive as a string
236 + * Normalize a recipient list that might arrive as a string
296 237 * ("a@x.com, b@x.com") or as an array.
297 238 *
298 239 * @param mixed $raw
299 240 * @return string[]
@@ -318,86 +259,19 @@
318 259 return array_values(array_unique($emails));
319 260 }
320 261
321 262 /**
322 - * Resolve an optional URL field with partial-update semantics.
323 - * Free plan: always null. Pro: prefer input, fall back to existing.
324 - */
325 - private function resolve_optional_url(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
326 - if (empty($caps[$cap_key])) {
327 - return null;
328 - }
329 - if (array_key_exists($field, $input)) {
330 - return $this->sanitize_url($input[$field], $max_len);
331 - }
332 - return is_string($existing) && $existing !== '' ? $existing : null;
333 - }
334 -
335 - private function resolve_optional_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
336 - if (empty($caps[$cap_key])) {
337 - return null;
338 - }
339 - if (array_key_exists($field, $input)) {
340 - $raw = $input[$field];
341 - return is_string($raw)
342 - ? $this->trim_to(sanitize_text_field($raw), $max_len)
343 - : null;
344 - }
345 - return is_string($existing) && $existing !== '' ? $existing : null;
346 - }
347 -
348 - private function resolve_optional_rich_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
349 - if (empty($caps[$cap_key])) {
350 - return null;
351 - }
352 - if (array_key_exists($field, $input)) {
353 - return $this->sanitize_rich_text($input[$field], $max_len);
354 - }
355 - return is_string($existing) && $existing !== '' ? $existing : null;
356 - }
357 -
358 - private function sanitize_url($raw, int $max_len): ?string {
359 - if (!is_string($raw) || $raw === '') {
360 - return null;
361 - }
362 - $url = esc_url_raw(trim($raw));
363 - if ($url === '') {
364 - return null;
365 - }
366 - return $this->trim_to($url, $max_len);
367 - }
368 -
369 - private function sanitize_rich_text($raw, int $max_len): ?string {
370 - if (!is_string($raw) || $raw === '') {
371 - return null;
372 - }
373 - $clean = wp_kses_post($raw);
374 - return $this->trim_to($clean, $max_len);
375 - }
376 -
377 - private function sanitize_css($raw, int $max_len): ?string {
378 - if (!is_string($raw) || $raw === '') {
379 - return null;
380 - }
381 - // wp_strip_all_tags + length cap is enough — actual CSS-in-email
382 - // safety is an email-client problem we can't solve server-side.
383 - $clean = wp_strip_all_tags($raw);
384 - return $this->trim_to($clean, $max_len);
385 - }
386 -
387 - /**
263 + * Keep known section keys, in the order given.
264 + *
388 265 * @param mixed $raw
389 266 * @return string[]
390 267 */
391 - private function sanitize_section_keys($raw): array {
268 + private function normalize_section_keys($raw): array {
392 269 if (!is_array($raw)) {
393 270 return [];
394 271 }
395 - $allowed = function_exists('thinkrank_get_email_report_default_sections')
396 - ? array_keys(thinkrank_get_email_report_default_sections())
397 - : [];
398 272 $allowed = array_unique(array_merge(
399 - $allowed,
273 + array_keys(thinkrank_get_email_report_default_sections()),
400 274 (array) apply_filters('thinkrank_email_report_section_keys', [])
401 275 ));
402 276 $clean = [];
403 277 foreach ($raw as $key) {
@@ -409,13 +283,6 @@
409 283 $clean[] = $key;
410 284 }
411 285 }
412 286 return array_values(array_unique($clean));
413 - }
414 -
415 - private function trim_to(string $value, int $max_len): string {
416 - if (function_exists('mb_substr')) {
417 - return mb_substr($value, 0, $max_len);
418 - }
419 - return substr($value, 0, $max_len);
420 287 }
421 288 }