*/ const NOTIFICATION_KEYS = [ 'security_updates', 'mega_offers', 'marketing_articles', ]; /** Register the scheduled delivery handler. */ public function __construct() { add_action( PluginNotificationScheduler::HOOK_SEND_PLUGIN_NOTIFICATION_SETTINGS, [$this, 'deliver'], 10, 2 ); add_action('wp_loaded', [$this, 'recoverDelivery'], 20); } /** * Default values stored by the Easy Setup wizard. * * @return array{name: string, email: string, security_updates: bool, mega_offers: bool, marketing_articles: bool} */ public static function defaults(): array { return [ 'name' => '', 'email' => '', 'security_updates' => true, 'mega_offers' => true, 'marketing_articles' => true, ]; } /** * The signed-in administrator's full name, with safe WordPress fallbacks. */ public static function getCurrentAdminName(): string { $user = wp_get_current_user(); if (! $user || ! $user->exists()) { return ''; } $full_name = trim( sanitize_text_field((string) $user->first_name) . ' ' . sanitize_text_field((string) $user->last_name) ); if ('' !== $full_name) { return $full_name; } $display_name = sanitize_text_field((string) $user->display_name); return '' !== $display_name ? $display_name : sanitize_text_field((string) $user->user_login); } /** * Read and normalize the dedicated JSON option. * * @return array{name: string, email: string, security_updates: bool, mega_offers: bool, marketing_articles: bool} */ public static function getStored(): array { $stored = get_option(self::OPTION_NAME, ''); if (! is_string($stored) || '' === $stored) { return self::defaults(); } $decoded = json_decode($stored, true); return is_array($decoded) ? self::normalize($decoded) : self::defaults(); } /** The site's configured administrator email, never a shopper address. */ private static function getAdminEmail(): string { return sanitize_email((string) get_option('admin_email', '')); } /** Current recipient fallbacks shared by both preference editors. */ public static function recipientDefaults(): array { return [ 'adminName' => self::getCurrentAdminName(), 'adminEmail' => self::getAdminEmail(), ]; } /** * Validate recipient overrides shared by the wizard and Help page. * * @param array $settings Submitted notification preferences. * @return array|WP_Error */ public static function sanitize(array $settings) { $email = is_scalar($settings['email'] ?? null) ? trim((string) $settings['email']) : ''; if ('' !== $email && ! is_email($email)) { return new WP_Error( 'invalid_plugin_notification_email', esc_html__('Enter a valid notification email address.', 'arrayfunnel'), ['status' => 400] ); } $settings = self::normalize($settings); if (self::getCurrentAdminName() === $settings['name']) { $settings['name'] = ''; } if (0 === strcasecmp($settings['email'], self::getAdminEmail())) { $settings['email'] = ''; } return $settings; } /** * Merge only edited values so an older editor cannot overwrite another save. * * @param array $settings Edited preferences. * @param array $initial_settings Preferences originally loaded by the editor. * @return true|WP_Error */ public static function saveChanges(array $settings, array $initial_settings) { $current = self::getStored(); foreach (array_keys(self::defaults()) as $key) { if ($settings[$key] !== $initial_settings[$key]) { $current[$key] = $settings[$key]; } } return self::saveAndSchedule($current); } /** * Save the JSON option and schedule delivery only when effective data changed. * * @param array $settings Sanitized notification settings. * @return true|WP_Error */ public static function saveAndSchedule(array $settings) { $settings = self::normalize($settings); $encoded = wp_json_encode($settings); if (! is_string($encoded)) { return new WP_Error( 'invalid_plugin_notification_settings', esc_html__('Could not encode the plugin notification settings.', 'arrayfunnel') ); } if ( ! update_option(self::OPTION_NAME, $encoded, false) && get_option(self::OPTION_NAME, '') !== $encoded ) { return new WP_Error( 'plugin_notification_settings_not_saved', esc_html__('Failed to save plugin notification settings.', 'arrayfunnel') ); } // Notification delivery is optional infrastructure. Once the local choice // is saved, a broken scheduler, filter or remote must never fail setup. try { self::prepareDelivery($settings); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Optional delivery infrastructure must never interrupt local preferences or WordPress. } catch (Throwable $exception) { // Keep the stored preferences and any pending payload for a later request. } return true; } /** * Capture one preference revision, including all-off removal requests. * * @param array $settings Latest committed preferences. */ private static function prepareDelivery(array $settings): void { // Another request may have saved newer choices before this one prepares. // Always capture the committed option, never an earlier caller's values. $settings = self::getStored(); $settings_json = wp_json_encode($settings); if (! is_string($settings_json)) { return; } $payload_data = self::buildPayloadData($settings); $fingerprint = self::fingerprint($payload_data); $last_queued = (string) get_option(self::QUEUED_FINGERPRINT_OPTION, ''); $pending = (array) get_option(self::PENDING_DELIVERY_OPTION, []); $acknowledged = (array) get_option(self::ACKNOWLEDGED_DELIVERY_OPTION, []); $revision = (int) get_option(self::PREFERENCE_UPDATED_AT_OPTION, 0); if (hash_equals($last_queued, $fingerprint) && ( (($pending['settings'] ?? '') === $settings_json && self::isCurrentPayload(json_decode((string) ($pending['payload'] ?? ''), true))) || (($acknowledged['settings'] ?? '') === $settings_json && (int) ($acknowledged['revision'] ?? -1) === $revision) )) { self::schedulePendingDelivery(); return; } // Milliseconds are monotonic locally even when two saves share a clock tick. // The portal uses the same revision on retries to reject stale preferences. $updated_at = max( (int) floor(microtime(true) * 1000), (int) get_option(self::PREFERENCE_UPDATED_AT_OPTION, 0) + 1 ); if ( ! update_option(self::PREFERENCE_UPDATED_AT_OPTION, $updated_at, false) && (int) get_option(self::PREFERENCE_UPDATED_AT_OPTION, 0) !== $updated_at ) { return; } // Persisting the new fingerprint also invalidates older queued jobs. if ( ! update_option(self::QUEUED_FINGERPRINT_OPTION, $fingerprint, false) && get_option(self::QUEUED_FINGERPRINT_OPTION, '') !== $fingerprint ) { return; } $payload = $payload_data; $payload['meta'] = [ 'trigger' => get_option(self::DELIVERY_COMPLETED_OPTION, false) ? 'update' : 'create', 'preference_updated_at' => $updated_at, ]; $payload_json = wp_json_encode($payload); if (! is_string($payload_json)) { return; } self::queueDelivery($payload_json, 0, time(), $settings_json); } /** * Keep unscheduled requests durable without exposing delivery errors to users. * * @param string $payload_json Captured delivery payload. * @param int $attempt Zero-based attempt number. * @param int $run_at Earliest delivery Unix timestamp. * @param string|null $settings_json Committed settings associated with this delivery. */ private static function queueDelivery(string $payload_json, int $attempt, int $run_at, ?string $settings_json = null): void { if (null === $settings_json) { $existing = (array) get_option(self::PENDING_DELIVERY_OPTION, []); if (($existing['payload'] ?? '') !== $payload_json) { return; } $settings_json = $existing['settings'] ?? ''; } if ($settings_json !== get_option(self::OPTION_NAME, '') || ! self::isCurrentPayload(json_decode($payload_json, true))) { return; } $pending = [ 'payload' => $payload_json, 'attempt' => $attempt, 'run_at' => $run_at, 'settings' => $settings_json, ]; if ( ! update_option(self::PENDING_DELIVERY_OPTION, $pending, false) && get_option(self::PENDING_DELIVERY_OPTION, []) !== $pending ) { return; } self::schedulePendingDelivery(); } /** Repair interrupted preparation or scheduling from the committed local choice. */ public static function recoverDelivery(): void { try { $settings_json = get_option(self::OPTION_NAME, ''); if (! is_string($settings_json) || '' === $settings_json) { return; } $stored = json_decode($settings_json, true); if (! is_array($stored)) { return; } foreach (self::NOTIFICATION_KEYS as $key) { if (! is_bool($stored[$key] ?? null)) { return; } } $pending = (array) get_option(self::PENDING_DELIVERY_OPTION, []); if (($pending['settings'] ?? '') === $settings_json && self::isCurrentPayload(json_decode((string) ($pending['payload'] ?? ''), true))) { self::schedulePendingDelivery(); return; } $acknowledged = (array) get_option(self::ACKNOWLEDGED_DELIVERY_OPTION, []); if (($acknowledged['settings'] ?? '') === $settings_json && (int) ($acknowledged['revision'] ?? -1) === (int) get_option(self::PREFERENCE_UPDATED_AT_OPTION, 0)) { return; } self::prepareDelivery(self::getStored()); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Optional delivery infrastructure must never interrupt local preferences or WordPress. } catch (Throwable $exception) { // Local choices remain saved even if recovery infrastructure fails. } } /** Retry a temporary scheduler failure on the next successful plugin load. */ public static function schedulePendingDelivery(): void { try { $pending = (array) get_option(self::PENDING_DELIVERY_OPTION, []); if (! is_array($pending) || ! is_string($pending['payload'] ?? null)) { return; } if (($pending['settings'] ?? '') !== get_option(self::OPTION_NAME, '')) { return; } $payload = json_decode($pending['payload'], true); if (! self::isCurrentPayload($payload)) { if (get_option(self::PENDING_DELIVERY_OPTION, []) === $pending) { delete_option(self::PENDING_DELIVERY_OPTION); } return; } if (! PluginNotificationScheduler::isAvailable()) { return; } $args = [$pending['payload'], (int) ($pending['attempt'] ?? 0)]; if (! PluginNotificationScheduler::isScheduled( PluginNotificationScheduler::HOOK_SEND_PLUGIN_NOTIFICATION_SETTINGS, $args, PluginNotificationScheduler::GROUP_EMAILS )) { PluginNotificationScheduler::scheduleSingle( PluginNotificationScheduler::HOOK_SEND_PLUGIN_NOTIFICATION_SETTINGS, max(time(), (int) ($pending['run_at'] ?? 0)), $args, PluginNotificationScheduler::GROUP_EMAILS ); } // Keep the request durable until an affirmative remote acknowledgement, // including when a scheduled action crashes or the service stays down. // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Optional delivery infrastructure must never interrupt local preferences or WordPress. } catch (Throwable $exception) { // Retry scheduling later; optional delivery never interrupts WordPress. } } /** * Check both values and revision, including repeated A-to-B-to-A edits. * * @param mixed $payload Decoded delivery payload. */ private static function isCurrentPayload($payload): bool { if (! is_array($payload) || ! is_array($payload['checked_options'] ?? null)) { return false; } $payload_data = $payload; unset($payload_data['meta']); if (! hash_equals( (string) get_option(self::QUEUED_FINGERPRINT_OPTION, ''), self::fingerprint($payload_data) )) { return false; } $revision = (int) ($payload['meta']['preference_updated_at'] ?? 0); return $revision === (int) get_option(self::PREFERENCE_UPDATED_AT_OPTION, 0); } /** * Deliver one queued webhook payload without failing its caller. * * @param string $payload_json JSON payload captured at save time. * @param int $attempt Zero-based delivery attempt. */ public function deliver(string $payload_json, int $attempt = 0): void { $lock_args = []; $locked = false; try { $payload = json_decode($payload_json, true); if ($attempt < 0 || ! self::isCurrentPayload($payload)) { return; } $pending = (array) get_option(self::PENDING_DELIVERY_OPTION, []); if (($pending['payload'] ?? '') !== $payload_json || (int) ($pending['attempt'] ?? -1) !== $attempt || ($pending['settings'] ?? '') !== get_option(self::OPTION_NAME, '')) { return; } $lock_args = [(string) get_option(self::QUEUED_FINGERPRINT_OPTION, '')]; $locked = PluginNotificationScheduler::acquireLock( PluginNotificationScheduler::HOOK_SEND_PLUGIN_NOTIFICATION_SETTINGS, $lock_args, 120 ); if (! self::isCurrentPayload($payload) || ($pending['settings'] ?? '') !== get_option(self::OPTION_NAME, '')) { return; } if ($locked && $this->postWebhook($payload_json)) { update_option(self::DELIVERY_COMPLETED_OPTION, 1, false); $pending = (array) get_option(self::PENDING_DELIVERY_OPTION, []); if (($pending['payload'] ?? '') === $payload_json) { $acknowledged = [ 'revision' => (int) ($payload['meta']['preference_updated_at'] ?? 0), 'settings' => $pending['settings'], ]; if (update_option(self::ACKNOWLEDGED_DELIVERY_OPTION, $acknowledged, false) || get_option(self::ACKNOWLEDGED_DELIVERY_OPTION, []) === $acknowledged) { delete_option(self::PENDING_DELIVERY_OPTION); } } return; } // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Optional delivery infrastructure must never interrupt local preferences or WordPress. } catch (Throwable $exception) { // HTTP implementations and filters may throw as well as return WP_Error. } finally { if ($locked) { try { PluginNotificationScheduler::releaseLock( PluginNotificationScheduler::HOOK_SEND_PLUGIN_NOTIFICATION_SETTINGS, $lock_args ); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Locks expire; optional cleanup failure must not escape cron. } catch (Throwable $exception) { // Locks expire; cleanup failure must not escape optional delivery. } } } try { // Retry quickly four times, then at most hourly until acknowledged. // A long outage must never permanently lose an opt-out request. $delay = $attempt + 1 >= self::FAST_DELIVERY_ATTEMPTS ? 60 * MINUTE_IN_SECONDS : MINUTE_IN_SECONDS * (2 ** $attempt); self::queueDelivery($payload_json, $attempt + 1, time() + $delay); // phpcs:ignore Generic.CodeAnalysis.EmptyStatement.DetectedCatch -- Optional delivery infrastructure must never interrupt local preferences or WordPress. } catch (Throwable $exception) { // Retain the durable pending request if scheduling cannot run now. } } /** * Require affirmative JSON so HTML error pages are retried silently. * * @param string $payload_json Captured delivery payload. */ private function postWebhook(string $payload_json): bool { $url = (string) apply_filters('arrayfunnel_plugin_notification_webhook_url', self::WEBHOOK_URL); $url = esc_url_raw($url); if (! wp_http_validate_url($url)) { return false; } $response = wp_remote_post($url, [ 'timeout' => 15, 'redirection' => 2, 'headers' => [ 'Accept' => 'application/json', 'Content-Type' => 'application/json', ], 'body' => $payload_json, 'data_format' => 'body', ]); if (is_wp_error($response)) { return false; } $status = (int) wp_remote_retrieve_response_code($response); $body = json_decode((string) wp_remote_retrieve_body($response), true); return $status >= 200 && $status < 300 && is_array($body) && true === ($body['ok'] ?? false); } /** * Build effective data sent to the portal, excluding delivery metadata. * * @param array $settings Normalized stored settings. * @return array */ private static function buildPayloadData(array $settings): array { $checked_options = []; foreach (self::NOTIFICATION_KEYS as $key) { if (! empty($settings[$key])) { $checked_options[] = $key; } } $email = '' !== $settings['email'] ? $settings['email'] : sanitize_email((string) self::getAdminEmail()); return [ 'product' => 'arrayfunnel', 'name' => '' !== $settings['name'] ? $settings['name'] : (self::getCurrentAdminName() ?: esc_html__('WordPress administrator', 'arrayfunnel')), 'email' => $email, 'site_language' => sanitize_text_field((string) get_locale()), 'site_url' => esc_url_raw(home_url('/')), 'checked_options' => $checked_options, ]; } /** * Produce a stable comparison fingerprint for effective payload data. * * @param array $payload_data Effective preference data without delivery metadata. */ private static function fingerprint(array $payload_data): string { $encoded = wp_json_encode($payload_data); return hash('sha256', is_string($encoded) ? $encoded : ''); } /** * Normalize stored overrides and preferences. * * @param array $settings Raw settings. * @return array{name: string, email: string, security_updates: bool, mega_offers: bool, marketing_articles: bool} */ private static function normalize(array $settings): array { $name = is_scalar($settings['name'] ?? null) ? sanitize_text_field(trim((string) $settings['name'])) : ''; $name = function_exists('mb_substr') ? mb_substr($name, 0, 120) : substr($name, 0, 120); $email = is_scalar($settings['email'] ?? null) ? sanitize_email(trim((string) $settings['email'])) : ''; return [ 'name' => $name, 'email' => is_email($email) ? $email : '', 'security_updates' => true === ($settings['security_updates'] ?? true), 'mega_offers' => true === ($settings['mega_offers'] ?? true), 'marketing_articles' => true === ($settings['marketing_articles'] ?? true), ]; } }