class-wcmq-settings.php 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264
  1. <?php
  2. /**
  3. * Settings and state option access.
  4. *
  5. * @package studiou-wc-mail-queue
  6. * @copyright 2026 QUADARAX
  7. * @author Dalibor Votruba <dvotruba@quadarax.com>
  8. * @license GPL-2.0-or-later
  9. */
  10. defined( 'ABSPATH' ) || exit;
  11. /**
  12. * Class Studiou_WCMQ_Settings
  13. */
  14. class Studiou_WCMQ_Settings {
  15. const OPTION = 'studiou_wcmq_settings';
  16. const STATE_OPTION = 'studiou_wcmq_state';
  17. const STATE_ENABLED = 'enabled';
  18. const STATE_DRAINING = 'draining';
  19. const STATE_DISABLED = 'disabled';
  20. /**
  21. * Email ids that WooCommerce produces at runtime but does not always
  22. * register in WC()->mailer()->get_emails().
  23. *
  24. * `WC_Email_Customer_Refunded_Order::trigger()` reassigns its own $this->id
  25. * to 'customer_partially_refunded_order' on a partial refund, but the class
  26. * that reports that id from get_emails() is only registered when the alpha
  27. * `block_email_editor` feature is on. Without this table the settings screen
  28. * can never offer the id, so partial-refund mail silently bypasses the queue.
  29. *
  30. * Re-audit on every WooCommerce major upgrade.
  31. *
  32. * id => id of the parent email whose checkbox it sits next to.
  33. */
  34. const RUNTIME_ALIAS_IDS = array(
  35. 'customer_partially_refunded_order' => 'customer_refunded_order',
  36. );
  37. /**
  38. * Cached, sanitized settings for this request.
  39. *
  40. * @var array|null
  41. */
  42. private static $cache = null;
  43. /**
  44. * Default settings.
  45. *
  46. * @return array
  47. */
  48. public static function defaults() {
  49. return array(
  50. 'rate_per_minute' => 5,
  51. 'handled_contexts' => array( 'customer_completed_order' ),
  52. 'retention_sent_days' => 7,
  53. 'retention_failed_days' => 30,
  54. 'retention_log_days' => 14,
  55. 'max_attempts' => 3,
  56. 'max_reclaims' => 5,
  57. 'claim_timeout' => 900,
  58. 'debug' => false,
  59. );
  60. }
  61. /**
  62. * All settings, sanitized on read.
  63. *
  64. * An option array is a mutable surface; never trust what comes back.
  65. *
  66. * @return array
  67. */
  68. public static function all() {
  69. if ( null === self::$cache ) {
  70. $raw = get_option( self::OPTION, array() );
  71. self::$cache = self::sanitize( is_array( $raw ) ? $raw : array() );
  72. }
  73. return self::$cache;
  74. }
  75. /**
  76. * One setting.
  77. *
  78. * @param string $key Setting key.
  79. * @return mixed
  80. */
  81. public static function get( $key ) {
  82. $all = self::all();
  83. return isset( $all[ $key ] ) ? $all[ $key ] : null;
  84. }
  85. /**
  86. * Persist settings.
  87. *
  88. * @param array $raw Raw input.
  89. */
  90. public static function update( array $raw ) {
  91. $clean = self::sanitize( $raw );
  92. update_option( self::OPTION, $clean, true );
  93. self::$cache = $clean;
  94. }
  95. /**
  96. * Coerce anything into a valid settings array.
  97. *
  98. * @param array $raw Raw input.
  99. * @return array
  100. */
  101. public static function sanitize( array $raw ) {
  102. $d = self::defaults();
  103. $contexts = array();
  104. if ( isset( $raw['handled_contexts'] ) && is_array( $raw['handled_contexts'] ) ) {
  105. foreach ( $raw['handled_contexts'] as $id ) {
  106. $id = sanitize_key( (string) $id );
  107. if ( '' !== $id ) {
  108. $contexts[] = $id;
  109. }
  110. }
  111. $contexts = array_values( array_unique( $contexts ) );
  112. } else {
  113. $contexts = $d['handled_contexts'];
  114. }
  115. return array(
  116. 'rate_per_minute' => self::clamp_int( $raw, 'rate_per_minute', 1, 60, $d['rate_per_minute'] ),
  117. 'handled_contexts' => $contexts,
  118. 'retention_sent_days' => self::clamp_int( $raw, 'retention_sent_days', 0, 3650, $d['retention_sent_days'] ),
  119. 'retention_failed_days' => self::clamp_int( $raw, 'retention_failed_days', 0, 3650, $d['retention_failed_days'] ),
  120. 'retention_log_days' => self::clamp_int( $raw, 'retention_log_days', 0, 3650, $d['retention_log_days'] ),
  121. 'max_attempts' => self::clamp_int( $raw, 'max_attempts', 1, 10, $d['max_attempts'] ),
  122. 'max_reclaims' => self::clamp_int( $raw, 'max_reclaims', 1, 20, $d['max_reclaims'] ),
  123. 'claim_timeout' => self::clamp_int( $raw, 'claim_timeout', 60, 86400, $d['claim_timeout'] ),
  124. 'debug' => ! empty( $raw['debug'] ),
  125. );
  126. }
  127. /**
  128. * Read an int from raw input and clamp it.
  129. *
  130. * Non-numeric input falls back to the default rather than to 0 — a 0 here
  131. * would reach the `60 / $rate` division.
  132. *
  133. * @param array $raw Raw input.
  134. * @param string $key Key.
  135. * @param int $min Minimum.
  136. * @param int $max Maximum.
  137. * @param int $default Fallback.
  138. * @return int
  139. */
  140. private static function clamp_int( array $raw, $key, $min, $max, $default ) {
  141. if ( ! isset( $raw[ $key ] ) || ! is_numeric( $raw[ $key ] ) ) {
  142. return (int) $default;
  143. }
  144. $value = (int) $raw[ $key ];
  145. if ( $value < $min ) {
  146. return (int) $min;
  147. }
  148. if ( $value > $max ) {
  149. return (int) $max;
  150. }
  151. return $value;
  152. }
  153. /**
  154. * Mails per minute, clamped to a sane range.
  155. *
  156. * @return int
  157. */
  158. public static function rate() {
  159. $rate = (int) self::get( 'rate_per_minute' );
  160. if ( $rate < 1 ) {
  161. $rate = 1;
  162. }
  163. if ( $rate > 60 ) {
  164. $rate = 60;
  165. }
  166. return $rate;
  167. }
  168. /**
  169. * Seconds between sends.
  170. *
  171. * ceil(), never floor(). floor() rounds the spacing down, which rounds the
  172. * rate UP above the configured cap for every rate that does not divide 60.
  173. * The clamp in rate() also guarantees no division by zero.
  174. *
  175. * @return int
  176. */
  177. public static function interval() {
  178. return max( 1, (int) ceil( 60 / self::rate() ) );
  179. }
  180. /**
  181. * Whether the debug toggle is on.
  182. *
  183. * Read straight from the option to avoid recursing through all()/log().
  184. *
  185. * @return bool
  186. */
  187. public static function debug() {
  188. $raw = get_option( self::OPTION, array() );
  189. return is_array( $raw ) && ! empty( $raw['debug'] );
  190. }
  191. /**
  192. * Handled email ids.
  193. *
  194. * @return array
  195. */
  196. public static function handled_contexts() {
  197. $contexts = self::get( 'handled_contexts' );
  198. return is_array( $contexts ) ? $contexts : array();
  199. }
  200. /**
  201. * Whether this email id should be queued.
  202. *
  203. * @param string $context_id WC_Email id.
  204. * @return bool
  205. */
  206. public static function handles( $context_id ) {
  207. if ( ! is_string( $context_id ) || '' === $context_id ) {
  208. return false;
  209. }
  210. return in_array( $context_id, self::handled_contexts(), true );
  211. }
  212. /* ---------------------------------------------------------------------
  213. * State
  214. * ------------------------------------------------------------------ */
  215. /**
  216. * Current state, defaulting to disabled.
  217. *
  218. * An mu-plugin activates the moment its file lands — there is no activation
  219. * step at which an admin confirms anything. Dropping the file must not start
  220. * intercepting live mail at an unconfigured rate. Fail closed.
  221. *
  222. * @return string
  223. */
  224. public static function state() {
  225. $state = get_option( self::STATE_OPTION, self::STATE_DISABLED );
  226. if ( ! in_array( $state, array( self::STATE_ENABLED, self::STATE_DRAINING, self::STATE_DISABLED ), true ) ) {
  227. return self::STATE_DISABLED;
  228. }
  229. return $state;
  230. }
  231. /**
  232. * Persist state.
  233. *
  234. * @param string $state New state.
  235. */
  236. public static function set_state( $state ) {
  237. if ( ! in_array( $state, array( self::STATE_ENABLED, self::STATE_DRAINING, self::STATE_DISABLED ), true ) ) {
  238. return;
  239. }
  240. update_option( self::STATE_OPTION, $state, true );
  241. }
  242. }