* @license GPL-2.0-or-later */ defined( 'ABSPATH' ) || exit; /** * Class Studiou_WCMQ_Admin */ class Studiou_WCMQ_Admin { const PAGE = 'studiou-wcmq'; const CAP = 'manage_woocommerce'; const NONCE = 'studiou-wcmq-nonce'; const PER_PAGE = 50; /** * Constructor. */ public function __construct() { add_action( 'admin_menu', array( $this, 'add_menu' ), 99 ); add_action( 'admin_enqueue_scripts', array( $this, 'enqueue_assets' ) ); add_action( 'admin_notices', array( $this, 'maybe_warn_deferred_emails' ) ); add_action( 'admin_notices', array( $this, 'maybe_warn_pluggable_wp_mail' ) ); add_action( 'admin_post_studiou_wcmq_save_settings', array( $this, 'handle_save_settings' ) ); add_action( 'admin_post_studiou_wcmq_toggle_state', array( $this, 'handle_toggle_state' ) ); add_action( 'admin_post_studiou_wcmq_clear_log', array( $this, 'handle_clear_log' ) ); add_action( 'admin_post_studiou_wcmq_queue_action', array( $this, 'handle_queue_action' ) ); } /** * Register the submenu under WooCommerce. */ public function add_menu() { add_submenu_page( 'woocommerce', __( 'Mail Queue', 'studiou-wc-mail-queue' ), __( 'Mail Queue', 'studiou-wc-mail-queue' ), self::CAP, self::PAGE, array( $this, 'render_page' ) ); } /** * Enqueue admin assets on our page only. * * @param string $hook Current admin page hook. */ public function enqueue_assets( $hook ) { if ( 'woocommerce_page_' . self::PAGE !== $hook ) { return; } wp_enqueue_style( 'studiou-wcmq-admin', STUDIOU_WCMQ_URL . 'assets/css/admin.css', array(), STUDIOU_WCMQ_VERSION ); wp_enqueue_script( 'studiou-wcmq-admin', STUDIOU_WCMQ_URL . 'assets/js/admin.js', array( 'jquery' ), STUDIOU_WCMQ_VERSION, true ); wp_localize_script( 'studiou-wcmq-admin', 'studiouWcmq', array( 'confirmDelete' => __( 'Delete the selected rows? This cannot be undone.', 'studiou-wc-mail-queue' ), 'confirmRetry' => __( 'Re-queue the selected rows? Already-sent rows are skipped, but anything still pending or failed will be sent again.', 'studiou-wc-mail-queue' ), 'confirmClearLog' => __( 'Clear the entire log? This cannot be undone.', 'studiou-wc-mail-queue' ), ) ); } /** * Warn when WooCommerce's own "Deferred emails" feature is on. * * It moves mail off the request but applies no rate cap, and chained with * this plugin it produces two queues in series. Never flip it silently. */ public function maybe_warn_deferred_emails() { if ( ! current_user_can( self::CAP ) ) { return; } if ( ! Studiou_WCMQ_State::is_queueing() ) { return; } $default = false; if ( class_exists( \Automattic\WooCommerce\Utilities\FeaturesUtil::class ) ) { $default = (bool) \Automattic\WooCommerce\Utilities\FeaturesUtil::feature_is_enabled( 'deferred_transactional_emails' ); } /** This filter is documented in woocommerce/includes/class-wc-emails.php */ $deferred = (bool) apply_filters( 'woocommerce_defer_transactional_emails', $default ); if ( ! $deferred ) { return; } ?>

:

getFileName() ); } catch ( Throwable $e ) { return; } $core = wp_normalize_path( ABSPATH . WPINC . '/pluggable.php' ); if ( $defined_in === $core ) { return; } ?>

: ' . esc_html( $defined_in ) . '' ); ?>

self::PAGE, 'tab' => $tab, ), $args ); return add_query_arg( $args, admin_url( 'admin.php' ) ); } /** * Email types the admin may choose to queue. * * Built from WC()->mailer()->get_emails(), then unioned with the runtime * alias ids. get_emails() alone is not sufficient: see * Studiou_WCMQ_Settings::RUNTIME_ALIAS_IDS. * * @return array id => label */ public static function available_contexts() { $out = array(); if ( function_exists( 'WC' ) && WC()->mailer() ) { $emails = WC()->mailer()->get_emails(); if ( is_array( $emails ) ) { foreach ( $emails as $email ) { if ( ! is_object( $email ) || empty( $email->id ) ) { continue; } $title = isset( $email->title ) && $email->title ? $email->title : $email->id; $out[ $email->id ] = $title; } } } foreach ( Studiou_WCMQ_Settings::RUNTIME_ALIAS_IDS as $alias_id => $parent_id ) { if ( isset( $out[ $alias_id ] ) ) { continue; } $parent_label = isset( $out[ $parent_id ] ) ? $out[ $parent_id ] : $parent_id; /* translators: %s: parent email title */ $out[ $alias_id ] = sprintf( __( '%s (partial)', 'studiou-wc-mail-queue' ), $parent_label ); } // Any id already selected but no longer offered stays visible, so an // upgrade cannot silently drop a setting the admin cannot see. foreach ( Studiou_WCMQ_Settings::handled_contexts() as $id ) { if ( ! isset( $out[ $id ] ) ) { $out[ $id ] = $id; } } asort( $out ); return $out; } /* --------------------------------------------------------------------- * Handlers * ------------------------------------------------------------------ */ /** * Verify nonce + capability, or die. */ private function guard() { if ( ! current_user_can( self::CAP ) ) { wp_die( esc_html__( 'You do not have permission to do that.', 'studiou-wc-mail-queue' ) ); } check_admin_referer( self::NONCE ); } /** * Redirect back to a tab. * * @param string $tab Tab slug. */ private function redirect( $tab ) { wp_safe_redirect( self::tab_url( $tab ) ); exit; } /** * Save the settings form. */ public function handle_save_settings() { $this->guard(); // phpcs:disable WordPress.Security.ValidatedSanitizedInput.InputNotSanitized $raw = array( 'rate_per_minute' => isset( $_POST['rate_per_minute'] ) ? wp_unslash( $_POST['rate_per_minute'] ) : '', 'handled_contexts' => isset( $_POST['handled_contexts'] ) ? (array) wp_unslash( $_POST['handled_contexts'] ) : array(), 'retention_sent_days' => isset( $_POST['retention_sent_days'] ) ? wp_unslash( $_POST['retention_sent_days'] ) : '', 'retention_failed_days' => isset( $_POST['retention_failed_days'] ) ? wp_unslash( $_POST['retention_failed_days'] ) : '', 'retention_log_days' => isset( $_POST['retention_log_days'] ) ? wp_unslash( $_POST['retention_log_days'] ) : '', 'max_attempts' => isset( $_POST['max_attempts'] ) ? wp_unslash( $_POST['max_attempts'] ) : '', 'max_reclaims' => isset( $_POST['max_reclaims'] ) ? wp_unslash( $_POST['max_reclaims'] ) : '', 'claim_timeout' => isset( $_POST['claim_timeout'] ) ? wp_unslash( $_POST['claim_timeout'] ) : '', 'debug' => ! empty( $_POST['debug'] ), ); // phpcs:enable Studiou_WCMQ_Settings::update( $raw ); Studiou_WCMQ_Utils_Log::message( __( 'Settings saved.', 'studiou-wc-mail-queue' ), 'success' ); $this->redirect( 'settings' ); } /** * Enable or disable queueing. */ public function handle_toggle_state() { $this->guard(); $want = isset( $_POST['wcmq_state'] ) ? sanitize_key( wp_unslash( $_POST['wcmq_state'] ) ) : ''; if ( Studiou_WCMQ_Settings::STATE_ENABLED === $want ) { Studiou_WCMQ_State::enable(); Studiou_WCMQ_Utils_Log::message( __( 'Mail queue enabled.', 'studiou-wc-mail-queue' ), 'success' ); } else { Studiou_WCMQ_State::disable(); if ( Studiou_WCMQ_Settings::STATE_DRAINING === Studiou_WCMQ_State::get() ) { Studiou_WCMQ_Utils_Log::message( __( 'Mail queue is draining. New mail sends immediately; queued mail will finish first.', 'studiou-wc-mail-queue' ), 'info' ); } else { Studiou_WCMQ_Utils_Log::message( __( 'Mail queue disabled.', 'studiou-wc-mail-queue' ), 'success' ); } } $this->redirect( 'settings' ); } /** * Empty the log. */ public function handle_clear_log() { $this->guard(); $n = Studiou_WCMQ_DB::clear_log(); /* translators: %d: number of rows */ Studiou_WCMQ_Utils_Log::message( sprintf( __( 'Log cleared (%d entries).', 'studiou-wc-mail-queue' ), $n ), 'success' ); $this->redirect( 'log' ); } /** * Retry or delete queue rows. */ public function handle_queue_action() { $this->guard(); $action = isset( $_POST['wcmq_action'] ) ? sanitize_key( wp_unslash( $_POST['wcmq_action'] ) ) : ''; $ids = isset( $_POST['rows'] ) ? array_map( 'absint', (array) wp_unslash( $_POST['rows'] ) ) : array(); $ids = array_filter( $ids ); if ( empty( $ids ) || ! in_array( $action, array( 'retry', 'delete' ), true ) ) { Studiou_WCMQ_Utils_Log::message( __( 'Nothing to do.', 'studiou-wc-mail-queue' ), 'info' ); $this->redirect( 'queue' ); } $done = 0; $failed = 0; $skipped_live = 0; $skipped_sent = 0; $now = time(); $claim_timeout = (int) Studiou_WCMQ_Settings::get( 'claim_timeout' ); foreach ( $ids as $row_id ) { $row = Studiou_WCMQ_DB::get_row( $row_id ); if ( ! $row ) { continue; } // A row a worker is actively holding must not be touched by either // action: retrying it delivers the mail twice, and deleting it pulls // the attachment directory out from under a wp_mail() that is still // reading it. Once the claim is older than claim_timeout the worker is // presumed dead (same rule the reaper uses) and the row is fair game. if ( Studiou_WCMQ_DB::STATE_SENDING === $row->state ) { $claimed_at = (int) $row->claimed_at; if ( $claimed_at > 0 && ( $now - $claimed_at ) < $claim_timeout ) { ++$skipped_live; continue; } } if ( 'delete' === $action ) { // Drop any scheduled action first, so Action Scheduler does not // wake a worker for a row that no longer exists. if ( function_exists( 'as_unschedule_all_actions' ) ) { as_unschedule_all_actions( Studiou_WCMQ_Queue::HOOK, array( (int) $row_id ), Studiou_WCMQ_Queue::GROUP ); } Studiou_WCMQ_Queue::delete_attachment_dir( $row->attachment_dir ); if ( Studiou_WCMQ_DB::delete_row( $row_id ) ) { ++$done; } else { ++$failed; } continue; } // NEVER re-queue a row that already went out. Its attachment directory // was deleted on send, so the resend would arrive with missing files — // and the customer would receive the email a second time. Select-all on // the "All" or "Sent" filter must be harmless. if ( Studiou_WCMQ_DB::STATE_SENT === $row->state ) { ++$skipped_sent; continue; } // Retry: a fresh attempt budget, a fresh crash budget, a fresh slot. // Pass the state we observed as the guard: if a worker has claimed this // row (pending -> sending) since we read it, reschedule() changes 0 // rows and returns false rather than flipping a mid-send row back to // pending and double-delivering. reschedule() also drops any stale // action itself, so no separate unschedule here. $ok = Studiou_WCMQ_Queue::reschedule( $row_id, 0, array( 'attempts' => 0, 'reclaims' => 0, 'last_error' => null, ), $row->state ); if ( $ok ) { ++$done; } else { ++$failed; } } if ( 'delete' === $action ) { /* translators: %d: number of rows */ Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows deleted.', 'studiou-wc-mail-queue' ), $done ), 'success' ); } else { /* translators: %d: number of rows */ Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows re-queued.', 'studiou-wc-mail-queue' ), $done ), 'success' ); } if ( $failed ) { /* translators: %d: number of rows */ Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows could not be processed.', 'studiou-wc-mail-queue' ), $failed ), 'warning' ); } if ( $skipped_live ) { /* translators: %d: number of rows */ Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows skipped because they are currently sending.', 'studiou-wc-mail-queue' ), $skipped_live ), 'info' ); } if ( $skipped_sent ) { /* translators: %d: number of rows */ Studiou_WCMQ_Utils_Log::message( sprintf( __( '%d rows skipped because they were already sent. Re-queueing them would deliver the email twice.', 'studiou-wc-mail-queue' ), $skipped_sent ), 'warning' ); } Studiou_WCMQ_State::maybe_settle(); $this->redirect( 'queue' ); } /* --------------------------------------------------------------------- * View helpers * ------------------------------------------------------------------ */ /** * Format a unix timestamp in site time. * * @param int|null $ts Timestamp. * @return string */ public static function format_time( $ts ) { $ts = (int) $ts; if ( $ts <= 0 ) { return '—'; } return esc_html( wp_date( 'Y-m-d H:i:s', $ts ) ); } /** * Current page number from the query string. * * @return int */ public static function current_page() { // phpcs:ignore WordPress.Security.NonceVerification.Recommended $paged = isset( $_GET['paged'] ) ? absint( wp_unslash( $_GET['paged'] ) ) : 1; return max( 1, $paged ); } /** * Render simple prev/next pagination. * * @param string $tab Tab slug. * @param int $total Total rows. * @param int $paged Current page. * @param array $args Extra query args. */ public static function render_pagination( $tab, $total, $paged, array $args = array() ) { $pages = (int) ceil( $total / self::PER_PAGE ); if ( $pages < 2 ) { return; } echo '
'; printf( '%s ', esc_html( sprintf( /* translators: %d: number of items */ _n( '%d item', '%d items', $total, 'studiou-wc-mail-queue' ), $total ) ) ); if ( $paged > 1 ) { printf( '« %s ', esc_url( self::tab_url( $tab, array_merge( $args, array( 'paged' => $paged - 1 ) ) ) ), esc_html__( 'Previous', 'studiou-wc-mail-queue' ) ); } printf( '%s ', esc_html( sprintf( /* translators: 1: current page, 2: total pages */ __( '%1$d of %2$d', 'studiou-wc-mail-queue' ), $paged, $pages ) ) ); if ( $paged < $pages ) { printf( '%s »', esc_url( self::tab_url( $tab, array_merge( $args, array( 'paged' => $paged + 1 ) ) ) ), esc_html__( 'Next', 'studiou-wc-mail-queue' ) ); } echo '
'; } }