studiou-wc-mail-queue.php 5.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194
  1. <?php
  2. /**
  3. * Plugin Name: QDR - Studiou WC Mail Queue
  4. * Plugin URI: https://www.quadarax.com/plugins/studiou-wc-mail-queue
  5. * Description: Intercepts outbound WooCommerce transactional mail and sends it through a persistent, rate-limited queue so bulk order-status changes cannot exceed the host's outbound mail limit.
  6. * Version: 1.0.0
  7. * Requires at least: 6.8
  8. * Tested up to: 6.9.4
  9. * Requires PHP: 8.2
  10. * WC requires at least: 9.8
  11. * WC tested up to: 10.9.4
  12. * Author: QUADARAX
  13. * Author URI: https://www.quadarax.com
  14. * License: GPL v2 or later
  15. * License URI: https://www.gnu.org/licenses/gpl-2.0.html
  16. * Text Domain: studiou-wc-mail-queue
  17. * Domain Path: /languages
  18. *
  19. * Copyright (C) 2026 QUADARAX. All rights reserved.
  20. * Contact: Dalibor Votruba <dvotruba@quadarax.com>
  21. *
  22. * This is an mu-plugin. It lives in a subdirectory and is loaded by the stub in
  23. * `loader/studiou-wc-mail-queue.php` — see that file for install instructions.
  24. *
  25. * @package studiou-wc-mail-queue
  26. * @copyright 2026 QUADARAX
  27. * @author Dalibor Votruba <dvotruba@quadarax.com>
  28. * @license GPL-2.0-or-later
  29. */
  30. defined( 'ABSPATH' ) || exit;
  31. define( 'STUDIOU_WCMQ_VERSION', '1.0.0' );
  32. define( 'STUDIOU_WCMQ_FILE', __FILE__ );
  33. define( 'STUDIOU_WCMQ_DIR', plugin_dir_path( __FILE__ ) );
  34. define( 'STUDIOU_WCMQ_URL', plugin_dir_url( __FILE__ ) );
  35. define( 'STUDIOU_WCMQ_BASENAME', plugin_basename( __FILE__ ) );
  36. /**
  37. * Declare HPOS compatibility.
  38. *
  39. * Named (not a closure) so a test harness or another plugin can remove_action it.
  40. */
  41. function studiou_wcmq_declare_hpos_compat() {
  42. if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) {
  43. \Automattic\WooCommerce\Utilities\FeaturesUtil::declare_compatibility( 'custom_order_tables', STUDIOU_WCMQ_FILE, true );
  44. }
  45. }
  46. add_action( 'before_woocommerce_init', 'studiou_wcmq_declare_hpos_compat' );
  47. /**
  48. * Main plugin class.
  49. *
  50. * Its only jobs: guard on WooCommerce, load dependencies, instantiate managers.
  51. * Managers register their own hooks in their constructors / init().
  52. */
  53. final class Studiou_WC_Mail_Queue {
  54. /**
  55. * Worker instance. Held so its hooks stay attached.
  56. *
  57. * @var Studiou_WCMQ_Worker|null
  58. */
  59. private $worker = null;
  60. /**
  61. * Admin instance.
  62. *
  63. * @var Studiou_WCMQ_Admin|null
  64. */
  65. private $admin = null;
  66. /**
  67. * Boot entry point.
  68. */
  69. public static function init_plugin() {
  70. $class = __CLASS__;
  71. new $class();
  72. }
  73. /**
  74. * Constructor.
  75. */
  76. public function __construct() {
  77. if ( ! class_exists( 'WooCommerce' ) ) {
  78. add_action( 'admin_notices', array( $this, 'woocommerce_missing_notice' ) );
  79. return;
  80. }
  81. $this->load_dependencies();
  82. Studiou_WCMQ_DB::maybe_upgrade_db();
  83. Studiou_WCMQ_Utils_Log::init();
  84. Studiou_WCMQ_Context::init();
  85. Studiou_WCMQ_Interceptor::init();
  86. Studiou_WCMQ_State::init();
  87. Studiou_WCMQ_Reaper::init();
  88. Studiou_WCMQ_Retention::init();
  89. $this->worker = new Studiou_WCMQ_Worker();
  90. if ( is_admin() ) {
  91. $this->admin = new Studiou_WCMQ_Admin();
  92. }
  93. // Action Scheduler only exists once WooCommerce has loaded it. An mu-plugin
  94. // runs before regular plugins, so nothing AS-dependent may happen at file
  95. // scope or at plugins_loaded. Defer scheduling to `init`.
  96. add_action( 'init', array( $this, 'schedule_recurring_actions' ), 20 );
  97. }
  98. /**
  99. * Load class files.
  100. */
  101. private function load_dependencies() {
  102. $files = array(
  103. 'utils-log.php',
  104. 'class-wcmq-settings.php',
  105. 'class-wcmq-db.php',
  106. 'class-wcmq-state.php',
  107. 'class-wcmq-context.php',
  108. 'class-wcmq-queue.php',
  109. 'class-wcmq-interceptor.php',
  110. 'class-wcmq-notifier.php',
  111. 'class-wcmq-worker.php',
  112. 'class-wcmq-reaper.php',
  113. 'class-wcmq-retention.php',
  114. );
  115. if ( is_admin() ) {
  116. $files[] = 'class-wcmq-admin.php';
  117. }
  118. foreach ( $files as $file ) {
  119. require_once STUDIOU_WCMQ_DIR . 'includes/' . $file;
  120. }
  121. }
  122. /**
  123. * Register the recurring reaper + retention actions, exactly once each.
  124. *
  125. * Gated to admin / cron / WP-CLI. The `as_next_scheduled_action()` pre-check
  126. * inside each is what keeps this idempotent, but it is still two DB queries,
  127. * and running them on every front-end page view buys nothing: those contexts
  128. * are exactly the ones that never need to bootstrap a schedule.
  129. */
  130. public function schedule_recurring_actions() {
  131. if ( ! function_exists( 'as_schedule_recurring_action' ) ) {
  132. return;
  133. }
  134. $should_schedule = is_admin() || wp_doing_cron() || ( defined( 'WP_CLI' ) && WP_CLI );
  135. if ( ! $should_schedule ) {
  136. return;
  137. }
  138. Studiou_WCMQ_Reaper::schedule();
  139. Studiou_WCMQ_Retention::schedule();
  140. }
  141. /**
  142. * Admin notice shown when WooCommerce is absent.
  143. */
  144. public function woocommerce_missing_notice() {
  145. ?>
  146. <div class="notice notice-error">
  147. <p><?php esc_html_e( 'Studiou WC Mail Queue requires WooCommerce to be installed and active.', 'studiou-wc-mail-queue' ); ?></p>
  148. </div>
  149. <?php
  150. }
  151. }
  152. /**
  153. * Load translations before anything instantiates.
  154. *
  155. * `load_plugin_textdomain()` resolves its relative path against WP_PLUGIN_DIR,
  156. * so for an mu-plugin it looks in `wp-content/plugins/...` — a directory that
  157. * does not exist here, and the catalogue silently never loads. The mu-plugin
  158. * equivalent, `load_muplugin_textdomain()`, resolves against WPMU_PLUGIN_DIR.
  159. *
  160. * Both check WP_LANG_DIR/plugins/ first, so a translation shipped by the site
  161. * still wins. The fallback keeps this working if the plugin is ever dropped into
  162. * wp-content/plugins/ as a regular plugin instead.
  163. */
  164. function studiou_wcmq_load_textdomain() {
  165. $rel_path = basename( dirname( __FILE__ ) ) . '/languages';
  166. if ( load_muplugin_textdomain( 'studiou-wc-mail-queue', $rel_path ) ) {
  167. return;
  168. }
  169. load_plugin_textdomain( 'studiou-wc-mail-queue', false, $rel_path );
  170. }
  171. add_action( 'plugins_loaded', 'studiou_wcmq_load_textdomain', 5 );
  172. add_action( 'plugins_loaded', array( 'Studiou_WC_Mail_Queue', 'init_plugin' ), 10 );