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-config.php +157 -260 1.28.0 → 2.9.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,234 +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 - // Seed next_scheduled_at on first enable so the UI shows a real
78 - // "Next report" date immediately. The scheduler still re-seeds on
79 - // its first tick for any other path that flips enable on.
80 - if ($sanitized['enabled'] && empty($sanitized['next_scheduled_at'])) {
81 - $sanitized['next_scheduled_at'] = wp_date(
82 - 'Y-m-d H:i:s',
83 - strtotime('+' . max(1, (int) $sanitized['frequency_days']) . ' days')
84 - );
85 - }
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 + ];
86 84
87 - update_option(self::OPTION_KEY, $sanitized, false);
88 -
89 85 /**
90 - * Fires after Email Report config is saved.
86 + * Filter what the report covers and who receives it.
91 87 *
92 - * Pro plugin uses this to re-validate its own added fields, refresh
93 - * 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.
94 91 *
95 - * @since 1.9.0
92 + * @since 2.6.0
96 93 *
97 - * @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.
98 100 */
99 - 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;
100 103
101 - 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 + );
102 113 }
103 114
104 115 /**
105 - * Pure sanitization — no DB writes. Useful for previews and tests.
116 + * Save the on/off switch. Returns the resolved config after the write.
106 117 *
107 - * Free vs. Pro behavior: Pro-only fields are accepted into the array
108 - * even on free, but their values are coerced to defaults if the user
109 - * isn't allowed to set them. Why keep them at all? So if the user
110 - * 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.
111 121 */
112 - public function sanitize(array $input): array {
113 - $defaults = $this->defaults();
114 - $caps = Plan_Config::email_report();
115 - $limits = function_exists('thinkrank_get_email_report_field_limits')
116 - ? thinkrank_get_email_report_field_limits()
117 - : [];
122 + public function save(array $input): array {
123 + $this->load_defaults_file();
118 124
119 - // Existing stored values are the baseline — partial updates (e.g.
120 - // a toggle-only POST or a Pro field added later) merge over the
121 - // saved config rather than reverting unsupplied keys to defaults.
122 - $stored = get_option(self::OPTION_KEY, []);
123 - if (!is_array($stored)) {
124 - $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);
125 129 }
126 - $existing = array_merge($defaults, $stored);
127 130
128 - $clean = [];
131 + if ($stored['enabled'] && empty($stored['next_scheduled_at'])) {
132 + $next = strtotime('+' . $this->get()['frequency_days'] . ' days');
129 133
130 - $clean['enabled'] = isset($input['enabled'])
131 - ? !empty($input['enabled'])
132 - : (bool) $existing['enabled'];
134 + $stored['next_scheduled_at'] = wp_date('Y-m-d H:i:s', max($next ?: time(), time()));
135 + }
133 136
134 - $clean['frequency_days'] = Plan_Config::clamp_email_report_frequency(
135 - isset($input['frequency_days']) ? (int) $input['frequency_days'] : (int) $existing['frequency_days']
136 - );
137 + update_option(self::OPTION_KEY, $stored, false);
137 138
138 - $clean['recipients'] = Plan_Config::clamp_email_report_recipients(
139 - $this->normalize_recipients($input['recipients'] ?? $existing['recipients'])
140 - );
139 + $config = $this->get();
141 140
142 - // Subject: free plan always uses the default. Pro: keep existing
143 - // when input doesn't include the key, accept new when it does.
144 - if (!empty($caps['custom_subject']) && array_key_exists('subject_template', $input)) {
145 - $subject = sanitize_text_field((string) $input['subject_template']);
146 - if ($subject === '') {
147 - $subject = (string) $defaults['subject_template'];
148 - }
149 - } elseif (!empty($caps['custom_subject'])) {
150 - $subject = (string) $existing['subject_template'];
151 - } else {
152 - $subject = (string) $defaults['subject_template'];
153 - }
154 - $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);
155 149
156 - // Logo URL: free plan stays null. Pro: only overwrite when the key
157 - // is present in input (so partial updates don't blank the logo).
158 - $clean['logo_url'] = $this->resolve_optional_url(
159 - $caps,
160 - 'custom_logo',
161 - $input,
162 - 'logo_url',
163 - $existing['logo_url'] ?? null,
164 - $limits['logo_url'] ?? 2048
165 - );
150 + return $config;
151 + }
166 152
167 - $clean['logo_link'] = $this->resolve_optional_url(
168 - $caps,
169 - 'logo_link',
170 - $input,
171 - 'logo_link',
172 - $existing['logo_link'] ?? null,
173 - $limits['logo_link'] ?? 2048
174 - );
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();
175 168
176 - $clean['header_background'] = $this->resolve_optional_text(
177 - $caps,
178 - 'header_background',
179 - $input,
180 - 'header_background',
181 - $existing['header_background'] ?? null,
182 - $limits['header_background'] ?? 500
183 - );
169 + if (empty($config['enabled']) || $previous_frequency_days === (int) $config['frequency_days']) {
170 + return $config;
171 + }
184 172
185 - // Free is forced to the default toggle (true) so the dashboard CTA
186 - // still appears. Pro: prefer input, fall back to existing, then default.
187 - $clean['link_to_full_report'] = empty($caps['link_to_full_report'])
188 - ? (bool) $defaults['link_to_full_report']
189 - : (
190 - array_key_exists('link_to_full_report', $input)
191 - ? (bool) $input['link_to_full_report']
192 - : (bool) $existing['link_to_full_report']
193 - );
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();
194 180
195 - $clean['intro_text'] = $this->resolve_optional_rich_text(
196 - $caps,
197 - 'intro_text',
198 - $input,
199 - 'intro_text',
200 - $existing['intro_text'] ?? null,
201 - $limits['intro_text'] ?? 5000
202 - );
181 + $next = strtotime('+' . (int) $config['frequency_days'] . ' days', $anchor);
203 182
204 - $clean['footer_text'] = $this->resolve_optional_rich_text(
205 - $caps,
206 - 'footer_text',
207 - $input,
208 - 'footer_text',
209 - $existing['footer_text'] ?? null,
210 - $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()))
211 188 );
189 + }
212 190
213 - $clean['additional_css'] = empty($caps['additional_css'])
214 - ? null
215 - : (
216 - array_key_exists('additional_css', $input)
217 - ? $this->sanitize_css($input['additional_css'], $limits['additional_css'] ?? 20000)
218 - : ($existing['additional_css'] ?? null)
219 - );
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 + }
220 209
221 - // Sections: Free is locked to all-on. Pro user submits the list,
222 - // falling back to existing when the key is missing.
223 - if (empty($caps['sections_configurable'])) {
224 - $clean['sections_enabled'] = (array) $defaults['sections_enabled'];
225 - } elseif (array_key_exists('sections_enabled', $input)) {
226 - $clean['sections_enabled'] = $this->sanitize_section_keys($input['sections_enabled']);
227 - } else {
228 - $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;
229 217 }
230 -
231 - // Schedule timestamps are server-managed — never trust client input.
232 - $clean['next_scheduled_at'] = $stored['next_scheduled_at'] ?? null;
233 - $clean['last_sent_at'] = $stored['last_sent_at'] ?? null;
234 -
235 - /**
236 - * Filter the sanitized config before persistence.
237 - *
238 - * Pro plugin uses this to sanitize fields it has added via
239 - * `thinkrank_email_report_config_schema`. The filter receives the
240 - * raw input alongside the sanitized output so consumers can read
241 - * pro-only field intent without re-parsing the request.
242 - *
243 - * @since 1.9.0
244 - *
245 - * @param array $clean Sanitized config so far.
246 - * @param array $input Raw input as received.
247 - */
248 - return apply_filters('thinkrank_email_report_config_sanitized', $clean, $input);
218 + unset($stored['last_skip']);
219 + update_option(self::OPTION_KEY, $stored, false);
249 220 }
250 221
251 222 /**
252 223 * Update only the schedule timestamps. Called from the scheduler after
253 - * a successful send so we don't round-trip the whole sanitize() flow
254 - * (the rest of the config hasn't changed).
224 + * a send.
255 225 */
256 226 public function update_schedule(?string $last_sent_at, ?string $next_scheduled_at): array {
257 - $current = $this->get();
258 - $current['last_sent_at'] = $last_sent_at;
259 - $current['next_scheduled_at'] = $next_scheduled_at;
260 - update_option(self::OPTION_KEY, $current, false);
261 - 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();
262 233 }
263 234
264 235 /**
265 - * Normalize a recipient input that might arrive as a string
236 + * Normalize a recipient list that might arrive as a string
266 237 * ("[email protected], [email protected]") or as an array.
267 238 *
268 239 * @param mixed $raw
269 240 * @return string[]
@@ -288,86 +259,19 @@
288 259 return array_values(array_unique($emails));
289 260 }
290 261
291 262 /**
292 - * Resolve an optional URL field with partial-update semantics.
293 - * Free plan: always null. Pro: prefer input, fall back to existing.
294 - */
295 - private function resolve_optional_url(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
296 - if (empty($caps[$cap_key])) {
297 - return null;
298 - }
299 - if (array_key_exists($field, $input)) {
300 - return $this->sanitize_url($input[$field], $max_len);
301 - }
302 - return is_string($existing) && $existing !== '' ? $existing : null;
303 - }
304 -
305 - private function resolve_optional_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
306 - if (empty($caps[$cap_key])) {
307 - return null;
308 - }
309 - if (array_key_exists($field, $input)) {
310 - $raw = $input[$field];
311 - return is_string($raw)
312 - ? $this->trim_to(sanitize_text_field($raw), $max_len)
313 - : null;
314 - }
315 - return is_string($existing) && $existing !== '' ? $existing : null;
316 - }
317 -
318 - private function resolve_optional_rich_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
319 - if (empty($caps[$cap_key])) {
320 - return null;
321 - }
322 - if (array_key_exists($field, $input)) {
323 - return $this->sanitize_rich_text($input[$field], $max_len);
324 - }
325 - return is_string($existing) && $existing !== '' ? $existing : null;
326 - }
327 -
328 - private function sanitize_url($raw, int $max_len): ?string {
329 - if (!is_string($raw) || $raw === '') {
330 - return null;
331 - }
332 - $url = esc_url_raw(trim($raw));
333 - if ($url === '') {
334 - return null;
335 - }
336 - return $this->trim_to($url, $max_len);
337 - }
338 -
339 - private function sanitize_rich_text($raw, int $max_len): ?string {
340 - if (!is_string($raw) || $raw === '') {
341 - return null;
342 - }
343 - $clean = wp_kses_post($raw);
344 - return $this->trim_to($clean, $max_len);
345 - }
346 -
347 - private function sanitize_css($raw, int $max_len): ?string {
348 - if (!is_string($raw) || $raw === '') {
349 - return null;
350 - }
351 - // wp_strip_all_tags + length cap is enough — actual CSS-in-email
352 - // safety is an email-client problem we can't solve server-side.
353 - $clean = wp_strip_all_tags($raw);
354 - return $this->trim_to($clean, $max_len);
355 - }
356 -
357 - /**
263 + * Keep known section keys, in the order given.
264 + *
358 265 * @param mixed $raw
359 266 * @return string[]
360 267 */
361 - private function sanitize_section_keys($raw): array {
268 + private function normalize_section_keys($raw): array {
362 269 if (!is_array($raw)) {
363 270 return [];
364 271 }
365 - $allowed = function_exists('thinkrank_get_email_report_default_sections')
366 - ? array_keys(thinkrank_get_email_report_default_sections())
367 - : [];
368 272 $allowed = array_unique(array_merge(
369 - $allowed,
273 + array_keys(thinkrank_get_email_report_default_sections()),
370 274 (array) apply_filters('thinkrank_email_report_section_keys', [])
371 275 ));
372 276 $clean = [];
373 277 foreach ($raw as $key) {
@@ -379,13 +283,6 @@
379 283 $clean[] = $key;
380 284 }
381 285 }
382 286 return array_values(array_unique($clean));
383 - }
384 -
385 - private function trim_to(string $value, int $max_len): string {
386 - if (function_exists('mb_substr')) {
387 - return mb_substr($value, 0, $max_len);
388 - }
389 - return substr($value, 0, $max_len);
390 287 }
391 288 }