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