| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513 |
- <?php
- /**
- * Admin screen: settings, queue and log tabs.
- *
- * @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_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;
- }
- ?>
- <div class="notice notice-warning">
- <p>
- <strong><?php esc_html_e( 'Studiou WC Mail Queue', 'studiou-wc-mail-queue' ); ?>:</strong>
- <?php esc_html_e( 'WooCommerce\'s "Deferred emails" feature is enabled. It moves mail off the request but applies no rate limit, and chained with this plugin it queues every email twice. Turn it off under WooCommerce → Settings → Advanced → Features.', 'studiou-wc-mail-queue' ); ?>
- </p>
- </div>
- <?php
- }
- /**
- * Warn when another plugin has replaced wp_mail() outright.
- *
- * `wp_mail()` is pluggable. A mailer that redefines it never applies
- * `pre_wp_mail` at all, so this plugin becomes silently, completely inert —
- * no error, no log line, mail leaving unthrottled. On a host that rate-limits
- * outbound mail an SMTP plugin is likely rather than hypothetical, so detect
- * it rather than documenting it and hoping.
- */
- public function maybe_warn_pluggable_wp_mail() {
- if ( ! current_user_can( self::CAP ) ) {
- return;
- }
- if ( ! Studiou_WCMQ_State::is_queueing() ) {
- return;
- }
- try {
- $reflection = new ReflectionFunction( 'wp_mail' );
- $defined_in = wp_normalize_path( (string) $reflection->getFileName() );
- } catch ( Throwable $e ) {
- return;
- }
- $core = wp_normalize_path( ABSPATH . WPINC . '/pluggable.php' );
- if ( $defined_in === $core ) {
- return;
- }
- ?>
- <div class="notice notice-error">
- <p>
- <strong><?php esc_html_e( 'Studiou WC Mail Queue', 'studiou-wc-mail-queue' ); ?>:</strong>
- <?php
- printf(
- /* translators: %s: file path where wp_mail() is defined */
- esc_html__( 'Another plugin has replaced the pluggable wp_mail() function (defined in %s). This plugin can only queue mail that passes through WordPress\'s own wp_mail(), so nothing is being throttled. Outbound mail is sending unthrottled.', 'studiou-wc-mail-queue' ),
- '<code>' . esc_html( $defined_in ) . '</code>'
- );
- ?>
- </p>
- </div>
- <?php
- }
- /* ---------------------------------------------------------------------
- * Rendering
- * ------------------------------------------------------------------ */
- /**
- * Render the tabbed page.
- */
- public function render_page() {
- if ( ! current_user_can( self::CAP ) ) {
- wp_die( esc_html__( 'You do not have permission to access this page.', 'studiou-wc-mail-queue' ) );
- }
- $tab = isset( $_GET['tab'] ) ? sanitize_key( wp_unslash( $_GET['tab'] ) ) : 'settings'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended
- if ( ! in_array( $tab, array( 'settings', 'queue', 'log' ), true ) ) {
- $tab = 'settings';
- }
- include STUDIOU_WCMQ_DIR . 'views/admin-page.php';
- }
- /**
- * URL of a tab on this page.
- *
- * @param string $tab Tab slug.
- * @param array $args Extra query args.
- * @return string
- */
- public static function tab_url( $tab, array $args = array() ) {
- $args = array_merge(
- array(
- 'page' => 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 '<div class="tablenav"><div class="tablenav-pages">';
- printf(
- '<span class="displaying-num">%s</span> ',
- esc_html( sprintf( /* translators: %d: number of items */ _n( '%d item', '%d items', $total, 'studiou-wc-mail-queue' ), $total ) )
- );
- if ( $paged > 1 ) {
- printf(
- '<a class="button" href="%s">« %s</a> ',
- esc_url( self::tab_url( $tab, array_merge( $args, array( 'paged' => $paged - 1 ) ) ) ),
- esc_html__( 'Previous', 'studiou-wc-mail-queue' )
- );
- }
- printf(
- '<span class="paging-input">%s</span> ',
- 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(
- '<a class="button" href="%s">%s »</a>',
- esc_url( self::tab_url( $tab, array_merge( $args, array( 'paged' => $paged + 1 ) ) ) ),
- esc_html__( 'Next', 'studiou-wc-mail-queue' )
- );
- }
- echo '</div></div>';
- }
- }
|