* @license GPL-2.0-or-later */ defined( 'ABSPATH' ) || exit; /** * Class Studiou_WCMQ_Settings */ class Studiou_WCMQ_Settings { const OPTION = 'studiou_wcmq_settings'; const STATE_OPTION = 'studiou_wcmq_state'; const STATE_ENABLED = 'enabled'; const STATE_DRAINING = 'draining'; const STATE_DISABLED = 'disabled'; /** * Email ids that WooCommerce produces at runtime but does not always * register in WC()->mailer()->get_emails(). * * `WC_Email_Customer_Refunded_Order::trigger()` reassigns its own $this->id * to 'customer_partially_refunded_order' on a partial refund, but the class * that reports that id from get_emails() is only registered when the alpha * `block_email_editor` feature is on. Without this table the settings screen * can never offer the id, so partial-refund mail silently bypasses the queue. * * Re-audit on every WooCommerce major upgrade. * * id => id of the parent email whose checkbox it sits next to. */ const RUNTIME_ALIAS_IDS = array( 'customer_partially_refunded_order' => 'customer_refunded_order', ); /** * Cached, sanitized settings for this request. * * @var array|null */ private static $cache = null; /** * Default settings. * * @return array */ public static function defaults() { return array( 'rate_per_minute' => 5, 'handled_contexts' => array( 'customer_completed_order' ), 'retention_sent_days' => 7, 'retention_failed_days' => 30, 'retention_log_days' => 14, 'max_attempts' => 3, 'max_reclaims' => 5, 'claim_timeout' => 900, 'debug' => false, ); } /** * All settings, sanitized on read. * * An option array is a mutable surface; never trust what comes back. * * @return array */ public static function all() { if ( null === self::$cache ) { $raw = get_option( self::OPTION, array() ); self::$cache = self::sanitize( is_array( $raw ) ? $raw : array() ); } return self::$cache; } /** * One setting. * * @param string $key Setting key. * @return mixed */ public static function get( $key ) { $all = self::all(); return isset( $all[ $key ] ) ? $all[ $key ] : null; } /** * Persist settings. * * @param array $raw Raw input. */ public static function update( array $raw ) { $clean = self::sanitize( $raw ); update_option( self::OPTION, $clean, true ); self::$cache = $clean; } /** * Coerce anything into a valid settings array. * * @param array $raw Raw input. * @return array */ public static function sanitize( array $raw ) { $d = self::defaults(); $contexts = array(); if ( isset( $raw['handled_contexts'] ) && is_array( $raw['handled_contexts'] ) ) { foreach ( $raw['handled_contexts'] as $id ) { $id = sanitize_key( (string) $id ); if ( '' !== $id ) { $contexts[] = $id; } } $contexts = array_values( array_unique( $contexts ) ); } else { $contexts = $d['handled_contexts']; } return array( 'rate_per_minute' => self::clamp_int( $raw, 'rate_per_minute', 1, 60, $d['rate_per_minute'] ), 'handled_contexts' => $contexts, 'retention_sent_days' => self::clamp_int( $raw, 'retention_sent_days', 0, 3650, $d['retention_sent_days'] ), 'retention_failed_days' => self::clamp_int( $raw, 'retention_failed_days', 0, 3650, $d['retention_failed_days'] ), 'retention_log_days' => self::clamp_int( $raw, 'retention_log_days', 0, 3650, $d['retention_log_days'] ), 'max_attempts' => self::clamp_int( $raw, 'max_attempts', 1, 10, $d['max_attempts'] ), 'max_reclaims' => self::clamp_int( $raw, 'max_reclaims', 1, 20, $d['max_reclaims'] ), 'claim_timeout' => self::clamp_int( $raw, 'claim_timeout', 60, 86400, $d['claim_timeout'] ), 'debug' => ! empty( $raw['debug'] ), ); } /** * Read an int from raw input and clamp it. * * Non-numeric input falls back to the default rather than to 0 — a 0 here * would reach the `60 / $rate` division. * * @param array $raw Raw input. * @param string $key Key. * @param int $min Minimum. * @param int $max Maximum. * @param int $default Fallback. * @return int */ private static function clamp_int( array $raw, $key, $min, $max, $default ) { if ( ! isset( $raw[ $key ] ) || ! is_numeric( $raw[ $key ] ) ) { return (int) $default; } $value = (int) $raw[ $key ]; if ( $value < $min ) { return (int) $min; } if ( $value > $max ) { return (int) $max; } return $value; } /** * Mails per minute, clamped to a sane range. * * @return int */ public static function rate() { $rate = (int) self::get( 'rate_per_minute' ); if ( $rate < 1 ) { $rate = 1; } if ( $rate > 60 ) { $rate = 60; } return $rate; } /** * Seconds between sends. * * ceil(), never floor(). floor() rounds the spacing down, which rounds the * rate UP above the configured cap for every rate that does not divide 60. * The clamp in rate() also guarantees no division by zero. * * @return int */ public static function interval() { return max( 1, (int) ceil( 60 / self::rate() ) ); } /** * Whether the debug toggle is on. * * Read straight from the option to avoid recursing through all()/log(). * * @return bool */ public static function debug() { $raw = get_option( self::OPTION, array() ); return is_array( $raw ) && ! empty( $raw['debug'] ); } /** * Handled email ids. * * @return array */ public static function handled_contexts() { $contexts = self::get( 'handled_contexts' ); return is_array( $contexts ) ? $contexts : array(); } /** * Whether this email id should be queued. * * @param string $context_id WC_Email id. * @return bool */ public static function handles( $context_id ) { if ( ! is_string( $context_id ) || '' === $context_id ) { return false; } return in_array( $context_id, self::handled_contexts(), true ); } /* --------------------------------------------------------------------- * State * ------------------------------------------------------------------ */ /** * Current state, defaulting to disabled. * * An mu-plugin activates the moment its file lands — there is no activation * step at which an admin confirms anything. Dropping the file must not start * intercepting live mail at an unconfigured rate. Fail closed. * * @return string */ public static function state() { $state = get_option( self::STATE_OPTION, self::STATE_DISABLED ); if ( ! in_array( $state, array( self::STATE_ENABLED, self::STATE_DRAINING, self::STATE_DISABLED ), true ) ) { return self::STATE_DISABLED; } return $state; } /** * Persist state. * * @param string $state New state. */ public static function set_state( $state ) { if ( ! in_array( $state, array( self::STATE_ENABLED, self::STATE_DRAINING, self::STATE_DISABLED ), true ) ) { return; } update_option( self::STATE_OPTION, $state, true ); } }