PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.1.1
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.1.1
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 1.10.0 All 48 releases
thinkrank / includes / seo / class-email-report-config.php

class-email-report-config.php in ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO 2.1.1, at includes/seo/class-email-report-config.php

419 lines 15.7 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Email Report Config
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.
10 *
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).
14 *
15 * @package ThinkRank
16 * @subpackage SEO
17 * @since 1.9.0
18 */
19
20 declare(strict_types=1);
21
22 namespace ThinkRank\SEO;
23
24 use ThinkRank\Core\Plan_Config;
25
26 if (!defined('ABSPATH')) {
27 exit;
28 }
29
30 /**
31 * Email_Report_Config
32 *
33 * @since 1.9.0
34 */
35 final class Email_Report_Config {
36
37 private const OPTION_KEY = 'thinkrank_email_report_config';
38
39 /**
40 * Load defaults helper. Lazy-loads the config defaults file because
41 * it lives outside the autoloader path (it's procedural functions).
42 */
43 private function defaults(): array {
44 if (!function_exists('thinkrank_get_default_email_report_config')) {
45 require_once THINKRANK_PLUGIN_DIR . 'includes/config/email-report-settings-config.php';
46 }
47 return thinkrank_get_default_email_report_config();
48 }
49
50 /**
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 */
54 public function get(): array {
55 $stored = get_option(self::OPTION_KEY, []);
56 if (!is_array($stored)) {
57 $stored = [];
58 }
59 return array_merge($this->defaults(), $stored);
60 }
61
62 /**
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.
65 *
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.
69 */
70 public function save(array $input): array {
71 $sanitized = $this->sanitize($input);
72
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']);
76
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'];
81
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 /**
111 * Fires after Email Report config is saved.
112 *
113 * Pro plugin uses this to re-validate its own added fields, refresh
114 * an audit table, or trigger a re-schedule.
115 *
116 * @since 1.9.0
117 *
118 * @param array $sanitized The persisted config.
119 */
120 do_action('thinkrank_email_report_settings_saved', $sanitized);
121
122 return $sanitized;
123 }
124
125 /**
126 * Pure sanitization — no DB writes. Useful for previews and tests.
127 *
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.
132 */
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 : [];
139
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 = [];
146 }
147 $existing = array_merge($defaults, $stored);
148
149 $clean = [];
150
151 $clean['enabled'] = isset($input['enabled'])
152 ? !empty($input['enabled'])
153 : (bool) $existing['enabled'];
154
155 $clean['frequency_days'] = Plan_Config::clamp_email_report_frequency(
156 isset($input['frequency_days']) ? (int) $input['frequency_days'] : (int) $existing['frequency_days']
157 );
158
159 $clean['recipients'] = Plan_Config::clamp_email_report_recipients(
160 $this->normalize_recipients($input['recipients'] ?? $existing['recipients'])
161 );
162
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);
182
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 );
193
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 );
202
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 );
211
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 );
221
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 );
230
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
238 );
239
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 );
247
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'];
256 }
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);
276 }
277
278 /**
279 * 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).
282 */
283 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;
289 }
290
291 /**
292 * Normalize a recipient input that might arrive as a string
293 * ("a@x.com, b@x.com") or as an array.
294 *
295 * @param mixed $raw
296 * @return string[]
297 */
298 private function normalize_recipients($raw): array {
299 if (is_string($raw)) {
300 $raw = preg_split('/[\s,;]+/', $raw) ?: [];
301 }
302 if (!is_array($raw)) {
303 return [];
304 }
305 $emails = [];
306 foreach ($raw as $candidate) {
307 if (!is_string($candidate)) {
308 continue;
309 }
310 $candidate = sanitize_email(trim($candidate));
311 if ($candidate !== '' && is_email($candidate)) {
312 $emails[] = strtolower($candidate);
313 }
314 }
315 return array_values(array_unique($emails));
316 }
317
318 /**
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 /**
385 * @param mixed $raw
386 * @return string[]
387 */
388 private function sanitize_section_keys($raw): array {
389 if (!is_array($raw)) {
390 return [];
391 }
392 $allowed = function_exists('thinkrank_get_email_report_default_sections')
393 ? array_keys(thinkrank_get_email_report_default_sections())
394 : [];
395 $allowed = array_unique(array_merge(
396 $allowed,
397 (array) apply_filters('thinkrank_email_report_section_keys', [])
398 ));
399 $clean = [];
400 foreach ($raw as $key) {
401 if (!is_string($key)) {
402 continue;
403 }
404 $key = sanitize_key($key);
405 if (in_array($key, $allowed, true)) {
406 $clean[] = $key;
407 }
408 }
409 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 }
418 }
419