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

413 lines 15.2 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 if (!empty($caps['custom_subject']) && array_key_exists('subject_template', $input)) {
166 $subject = sanitize_text_field((string) $input['subject_template']);
167 if ($subject === '') {
168 $subject = (string) $defaults['subject_template'];
169 }
170 } elseif (!empty($caps['custom_subject'])) {
171 $subject = (string) $existing['subject_template'];
172 } else {
173 $subject = (string) $defaults['subject_template'];
174 }
175 $clean['subject_template'] = $this->trim_to($subject, $limits['subject_template'] ?? 200);
176
177 // Logo URL: free plan stays null. Pro: only overwrite when the key
178 // is present in input (so partial updates don't blank the logo).
179 $clean['logo_url'] = $this->resolve_optional_url(
180 $caps,
181 'custom_logo',
182 $input,
183 'logo_url',
184 $existing['logo_url'] ?? null,
185 $limits['logo_url'] ?? 2048
186 );
187
188 $clean['logo_link'] = $this->resolve_optional_url(
189 $caps,
190 'logo_link',
191 $input,
192 'logo_link',
193 $existing['logo_link'] ?? null,
194 $limits['logo_link'] ?? 2048
195 );
196
197 $clean['header_background'] = $this->resolve_optional_text(
198 $caps,
199 'header_background',
200 $input,
201 'header_background',
202 $existing['header_background'] ?? null,
203 $limits['header_background'] ?? 500
204 );
205
206 // Free is forced to the default toggle (true) so the dashboard CTA
207 // still appears. Pro: prefer input, fall back to existing, then default.
208 $clean['link_to_full_report'] = empty($caps['link_to_full_report'])
209 ? (bool) $defaults['link_to_full_report']
210 : (
211 array_key_exists('link_to_full_report', $input)
212 ? (bool) $input['link_to_full_report']
213 : (bool) $existing['link_to_full_report']
214 );
215
216 $clean['intro_text'] = $this->resolve_optional_rich_text(
217 $caps,
218 'intro_text',
219 $input,
220 'intro_text',
221 $existing['intro_text'] ?? null,
222 $limits['intro_text'] ?? 5000
223 );
224
225 $clean['footer_text'] = $this->resolve_optional_rich_text(
226 $caps,
227 'footer_text',
228 $input,
229 'footer_text',
230 $existing['footer_text'] ?? null,
231 $limits['footer_text'] ?? 5000
232 );
233
234 $clean['additional_css'] = empty($caps['additional_css'])
235 ? null
236 : (
237 array_key_exists('additional_css', $input)
238 ? $this->sanitize_css($input['additional_css'], $limits['additional_css'] ?? 20000)
239 : ($existing['additional_css'] ?? null)
240 );
241
242 // Sections: Free is locked to all-on. Pro user submits the list,
243 // falling back to existing when the key is missing.
244 if (empty($caps['sections_configurable'])) {
245 $clean['sections_enabled'] = (array) $defaults['sections_enabled'];
246 } elseif (array_key_exists('sections_enabled', $input)) {
247 $clean['sections_enabled'] = $this->sanitize_section_keys($input['sections_enabled']);
248 } else {
249 $clean['sections_enabled'] = (array) $existing['sections_enabled'];
250 }
251
252 // Schedule timestamps are server-managed — never trust client input.
253 $clean['next_scheduled_at'] = $stored['next_scheduled_at'] ?? null;
254 $clean['last_sent_at'] = $stored['last_sent_at'] ?? null;
255
256 /**
257 * Filter the sanitized config before persistence.
258 *
259 * Pro plugin uses this to sanitize fields it has added via
260 * `thinkrank_email_report_config_schema`. The filter receives the
261 * raw input alongside the sanitized output so consumers can read
262 * pro-only field intent without re-parsing the request.
263 *
264 * @since 1.9.0
265 *
266 * @param array $clean Sanitized config so far.
267 * @param array $input Raw input as received.
268 */
269 return apply_filters('thinkrank_email_report_config_sanitized', $clean, $input);
270 }
271
272 /**
273 * Update only the schedule timestamps. Called from the scheduler after
274 * a successful send so we don't round-trip the whole sanitize() flow
275 * (the rest of the config hasn't changed).
276 */
277 public function update_schedule(?string $last_sent_at, ?string $next_scheduled_at): array {
278 $current = $this->get();
279 $current['last_sent_at'] = $last_sent_at;
280 $current['next_scheduled_at'] = $next_scheduled_at;
281 update_option(self::OPTION_KEY, $current, false);
282 return $current;
283 }
284
285 /**
286 * Normalize a recipient input that might arrive as a string
287 * ("a@x.com, b@x.com") or as an array.
288 *
289 * @param mixed $raw
290 * @return string[]
291 */
292 private function normalize_recipients($raw): array {
293 if (is_string($raw)) {
294 $raw = preg_split('/[\s,;]+/', $raw) ?: [];
295 }
296 if (!is_array($raw)) {
297 return [];
298 }
299 $emails = [];
300 foreach ($raw as $candidate) {
301 if (!is_string($candidate)) {
302 continue;
303 }
304 $candidate = sanitize_email(trim($candidate));
305 if ($candidate !== '' && is_email($candidate)) {
306 $emails[] = strtolower($candidate);
307 }
308 }
309 return array_values(array_unique($emails));
310 }
311
312 /**
313 * Resolve an optional URL field with partial-update semantics.
314 * Free plan: always null. Pro: prefer input, fall back to existing.
315 */
316 private function resolve_optional_url(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
317 if (empty($caps[$cap_key])) {
318 return null;
319 }
320 if (array_key_exists($field, $input)) {
321 return $this->sanitize_url($input[$field], $max_len);
322 }
323 return is_string($existing) && $existing !== '' ? $existing : null;
324 }
325
326 private function resolve_optional_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
327 if (empty($caps[$cap_key])) {
328 return null;
329 }
330 if (array_key_exists($field, $input)) {
331 $raw = $input[$field];
332 return is_string($raw)
333 ? $this->trim_to(sanitize_text_field($raw), $max_len)
334 : null;
335 }
336 return is_string($existing) && $existing !== '' ? $existing : null;
337 }
338
339 private function resolve_optional_rich_text(array $caps, string $cap_key, array $input, string $field, $existing, int $max_len): ?string {
340 if (empty($caps[$cap_key])) {
341 return null;
342 }
343 if (array_key_exists($field, $input)) {
344 return $this->sanitize_rich_text($input[$field], $max_len);
345 }
346 return is_string($existing) && $existing !== '' ? $existing : null;
347 }
348
349 private function sanitize_url($raw, int $max_len): ?string {
350 if (!is_string($raw) || $raw === '') {
351 return null;
352 }
353 $url = esc_url_raw(trim($raw));
354 if ($url === '') {
355 return null;
356 }
357 return $this->trim_to($url, $max_len);
358 }
359
360 private function sanitize_rich_text($raw, int $max_len): ?string {
361 if (!is_string($raw) || $raw === '') {
362 return null;
363 }
364 $clean = wp_kses_post($raw);
365 return $this->trim_to($clean, $max_len);
366 }
367
368 private function sanitize_css($raw, int $max_len): ?string {
369 if (!is_string($raw) || $raw === '') {
370 return null;
371 }
372 // wp_strip_all_tags + length cap is enough — actual CSS-in-email
373 // safety is an email-client problem we can't solve server-side.
374 $clean = wp_strip_all_tags($raw);
375 return $this->trim_to($clean, $max_len);
376 }
377
378 /**
379 * @param mixed $raw
380 * @return string[]
381 */
382 private function sanitize_section_keys($raw): array {
383 if (!is_array($raw)) {
384 return [];
385 }
386 $allowed = function_exists('thinkrank_get_email_report_default_sections')
387 ? array_keys(thinkrank_get_email_report_default_sections())
388 : [];
389 $allowed = array_unique(array_merge(
390 $allowed,
391 (array) apply_filters('thinkrank_email_report_section_keys', [])
392 ));
393 $clean = [];
394 foreach ($raw as $key) {
395 if (!is_string($key)) {
396 continue;
397 }
398 $key = sanitize_key($key);
399 if (in_array($key, $allowed, true)) {
400 $clean[] = $key;
401 }
402 }
403 return array_values(array_unique($clean));
404 }
405
406 private function trim_to(string $value, int $max_len): string {
407 if (function_exists('mb_substr')) {
408 return mb_substr($value, 0, $max_len);
409 }
410 return substr($value, 0, $max_len);
411 }
412 }
413