PluginProbe
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO / 2.5.0
ThinkRank AI SEO – AI SEO Plugin for WordPress: Schema, XML Sitemaps, Meta Tags, Search Console & Local SEO v2.5.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 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.5.0, at includes/seo/class-email-report-config.php

422 lines 15.9 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 // 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 /**
114 * Fires after Email Report config is saved.
115 *
116 * Pro plugin uses this to re-validate its own added fields, refresh
117 * an audit table, or trigger a re-schedule.
118 *
119 * @since 1.9.0
120 *
121 * @param array $sanitized The persisted config.
122 */
123 do_action('thinkrank_email_report_settings_saved', $sanitized);
124
125 return $sanitized;
126 }
127
128 /**
129 * Pure sanitization — no DB writes. Useful for previews and tests.
130 *
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.
135 */
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 : [];
142
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 = [];
149 }
150 $existing = array_merge($defaults, $stored);
151
152 $clean = [];
153
154 $clean['enabled'] = isset($input['enabled'])
155 ? !empty($input['enabled'])
156 : (bool) $existing['enabled'];
157
158 $clean['frequency_days'] = Plan_Config::clamp_email_report_frequency(
159 isset($input['frequency_days']) ? (int) $input['frequency_days'] : (int) $existing['frequency_days']
160 );
161
162 $clean['recipients'] = Plan_Config::clamp_email_report_recipients(
163 $this->normalize_recipients($input['recipients'] ?? $existing['recipients'])
164 );
165
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);
185
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 );
196
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 );
205
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 );
214
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 );
224
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 );
233
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
241 );
242
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 );
250
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'];
259 }
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);
279 }
280
281 /**
282 * 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).
285 */
286 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;
292 }
293
294 /**
295 * Normalize a recipient input that might arrive as a string
296 * ("a@x.com, b@x.com") or as an array.
297 *
298 * @param mixed $raw
299 * @return string[]
300 */
301 private function normalize_recipients($raw): array {
302 if (is_string($raw)) {
303 $raw = preg_split('/[\s,;]+/', $raw) ?: [];
304 }
305 if (!is_array($raw)) {
306 return [];
307 }
308 $emails = [];
309 foreach ($raw as $candidate) {
310 if (!is_string($candidate)) {
311 continue;
312 }
313 $candidate = sanitize_email(trim($candidate));
314 if ($candidate !== '' && is_email($candidate)) {
315 $emails[] = strtolower($candidate);
316 }
317 }
318 return array_values(array_unique($emails));
319 }
320
321 /**
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 /**
388 * @param mixed $raw
389 * @return string[]
390 */
391 private function sanitize_section_keys($raw): array {
392 if (!is_array($raw)) {
393 return [];
394 }
395 $allowed = function_exists('thinkrank_get_email_report_default_sections')
396 ? array_keys(thinkrank_get_email_report_default_sections())
397 : [];
398 $allowed = array_unique(array_merge(
399 $allowed,
400 (array) apply_filters('thinkrank_email_report_section_keys', [])
401 ));
402 $clean = [];
403 foreach ($raw as $key) {
404 if (!is_string($key)) {
405 continue;
406 }
407 $key = sanitize_key($key);
408 if (in_array($key, $allowed, true)) {
409 $clean[] = $key;
410 }
411 }
412 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 }
421 }
422