| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264 |
- <?php
- /**
- * Settings and state option access.
- *
- * @package studiou-wc-mail-queue
- * @copyright 2026 QUADARAX
- * @author Dalibor Votruba <dvotruba@quadarax.com>
- * @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 );
- }
- }
|