* @license GPL-2.0-or-later */ defined( 'ABSPATH' ) || exit; /** * Class Studiou_WCMQ_Interceptor */ class Studiou_WCMQ_Interceptor { /** * True while the worker is calling wp_mail() to actually send. * * Always cleared in a `finally`. A stuck `true` under Action Scheduler means * every later send in the batch bypasses the queue. * * @var bool */ public static $sending = false; /** * Register hooks. */ public static function init() { add_filter( 'pre_wp_mail', array( __CLASS__, 'maybe_enqueue' ), 10, 2 ); // WooCommerce 10.9's EmailLogger listens on woocommerce_email_sent, which // fires with $success = true the moment we short-circuit wp_mail(). Left // alone it writes an order note and a `transactional-emails` log line // claiming the mail was sent, up to (interval x queue depth) early — and // never reconciles it if the deferred send later fails permanently. // // Suppress on "was THIS mail queued", never on "is this email type // handled": a handled type still sends synchronously while draining, or // when Action Scheduler is missing, or when enqueue failed. In those // cases the note is correct and no worker will write a replacement. add_filter( 'woocommerce_email_log_enabled', array( __CLASS__, 'suppress_email_log' ), 10, 2 ); add_filter( 'woocommerce_email_log_add_order_note', array( __CLASS__, 'suppress_email_log' ), 10, 2 ); } /** * Suppress EmailLogger for the one mail we queued — and nothing else. * * Matching on the flag alone is not enough. `woocommerce_email_log_enabled` * is also applied from EmailLogger::log_non_send_outcome(), which runs on * `woocommerce_email_disabled` and `woocommerce_email_skipped`. Both of those * fire from WC_Email::send_notification() *before* send() is ever called, so * the probe never runs and never resets the flag. * * Concretely, on a stock shop: pending -> processing fires the customer * processing email (queued, flag set) and then the admin new-order email. If * the admin disabled that one — routine — we would silently swallow * WooCommerce's "not sent: email type is disabled" log line for it. * * Match the email id too, so only the mail we actually queued is suppressed. * * @param bool $enabled Current value. * @param string $email_id WC_Email id this filter is being applied for. * @return bool */ public static function suppress_email_log( $enabled, $email_id = '' ) { if ( ! Studiou_WCMQ_Context::was_queued() ) { return $enabled; } if ( (string) $email_id !== Studiou_WCMQ_Context::queued_context_id() ) { return $enabled; } return false; } /** * Short-circuit wp_mail() for handled WooCommerce email, or pass through. * * Returning true tells WordPress the mail was sent. We only ever do that * once the row and its Action Scheduler action are durably persisted. * Every failure path returns null so wp_mail() proceeds and the mail sends * synchronously — an unthrottled send beats a silently lost one. * * @param null|bool $short_circuit Null to continue. * @param array $atts wp_mail arguments. * @return null|bool */ public static function maybe_enqueue( $short_circuit, $atts ) { if ( self::$sending ) { return $short_circuit; } // Consume the context FIRST, unconditionally — before the $short_circuit // check, before the $atts shape check. If any of those early-returns ran // before consume(), an in-flight context would survive onto the next // wp_mail() in the request, which is the stale-context bug §3.1 exists to // prevent. This is why we read to/subject defensively here rather than // gating on the isset() first. $to = is_array( $atts ) && isset( $atts['to'] ) ? $atts['to'] : null; $subject = is_array( $atts ) && isset( $atts['subject'] ) ? $atts['subject'] : null; $ctx = Studiou_WCMQ_Context::consume( $to, $subject ); // WordPress threads each pre_wp_mail callback's return value into the // next. A non-null value means another plugin — typically a transactional // API mailer hooked at a lower priority — has ALREADY delivered (true) or // already failed (false) this message. Queueing it now would send it a // second time; returning true over their false would report a delivery // that never happened. Pass their verdict through untouched. if ( null !== $short_circuit ) { return $short_circuit; } if ( ! is_array( $atts ) || ! isset( $atts['to'], $atts['subject'], $atts['message'] ) ) { return $short_circuit; } // A WooCommerce email was in flight but its fingerprint did not match the // mail wp_mail() actually received — a wp_mail filter rewrote the // recipient or subject after we captured it. The mail is about to send // unthrottled. This is the difference between "nothing to do" and "the // plugin is being silently defeated", so make it visible. if ( isset( $ctx['status'] ) && 'mismatch' === $ctx['status'] ) { Studiou_WCMQ_DB::log( 'warning', sprintf( 'Email "%s" was not queued: a wp_mail filter changed the %s after WooCommerce rendered it, so it could not be matched. It is sending unthrottled.', isset( $ctx['id'] ) ? $ctx['id'] : '?', isset( $ctx['differs'] ) ? $ctx['differs'] : 'message' ) ); return $short_circuit; } if ( ! isset( $ctx['status'] ) || 'ok' !== $ctx['status'] || '' === $ctx['id'] ) { return $short_circuit; } // From here we have a real, matched WooCommerce email. Log it before every // decision so "why didn't this get queued?" is answerable from the log // (Phase 3 acceptance): one line per handled email, naming its type. Studiou_WCMQ_DB::log( 'debug', sprintf( 'Saw WooCommerce email "%s".', $ctx['id'] ) ); if ( ! Studiou_WCMQ_State::is_queueing() ) { return $short_circuit; } if ( ! Studiou_WCMQ_Settings::handles( $ctx['id'] ) ) { return $short_circuit; } if ( ! function_exists( 'as_schedule_single_action' ) ) { Studiou_WCMQ_DB::log( 'warning', 'Action Scheduler unavailable; sending synchronously.' ); return $short_circuit; } $payload = self::snapshot( $atts ); if ( null === $payload ) { return $short_circuit; } $row_id = Studiou_WCMQ_Queue::enqueue( $ctx, $payload ); if ( ! $row_id ) { // Queue::enqueue() already logged and rolled back. return $short_circuit; } Studiou_WCMQ_Context::mark_queued( $row_id, $ctx['id'] ); return true; } /** * Build a filter-independent snapshot of the mail. * * pre_wp_mail runs before wp_mail() applies wp_mail_from / wp_mail_from_name * / wp_mail_content_type, and WC_Email::send() detaches its own callbacks for * those the instant wp_mail() returns. A deferred send would therefore go out * as wordpress@{sitename}. Resolve them now, while WooCommerce's filters are * still attached, and bake the result into the stored headers. * * @param array $atts wp_mail arguments. * @return array|null */ private static function snapshot( array $atts ) { $headers = isset( $atts['headers'] ) ? $atts['headers'] : ''; $attachments = isset( $atts['attachments'] ) ? (array) $atts['attachments'] : array(); // Mirror wp_mail()'s own default so WooCommerce's filter receives what it // expects. get_from_address() ignores the argument anyway. $sitename = wp_parse_url( network_home_url(), PHP_URL_HOST ); $sitename = is_string( $sitename ) ? strtolower( $sitename ) : ''; if ( 0 === strpos( $sitename, 'www.' ) ) { $sitename = substr( $sitename, 4 ); } $default_from = 'wordpress@' . $sitename; /** This filter is documented in wp-includes/pluggable.php */ $from_address = apply_filters( 'wp_mail_from', $default_from ); /** This filter is documented in wp-includes/pluggable.php */ $from_name = apply_filters( 'wp_mail_from_name', 'WordPress' ); /** This filter is documented in wp-includes/pluggable.php */ $content_type = apply_filters( 'wp_mail_content_type', 'text/plain' ); return array( 'to' => $atts['to'], 'subject' => $atts['subject'], 'message' => $atts['message'], 'headers' => self::inject_headers( $headers, $from_address, $from_name, $content_type ), 'attachments' => $attachments, ); } /** * Add From: (and Content-Type: if missing) to the captured headers. * * WC_Email::get_headers() returns a \r\n-delimited STRING carrying * Content-Type and Reply-to — and never a From. Non-WooCommerce callers may * pass an array, so handle both. * * @param string|array $headers Original headers. * @param string $from_address Resolved sender address. * @param string $from_name Resolved sender name. * @param string $content_type Resolved content type. * @return string|array */ private static function inject_headers( $headers, $from_address, $from_name, $content_type ) { $was_array = is_array( $headers ); if ( $was_array ) { $lines = $headers; } else { // Split on line breaks, but NOT on a folded-header continuation: RFC // 5322 allows a long header value to wrap onto a following line that // begins with whitespace. Splitting there and trimming each line would // turn the continuation into a standalone colon-less line, which // wp_mail() then discards. WooCommerce's own get_headers() never // folds, so this only bites a third-party woocommerce_email_headers // filter — but preserve the fold rather than corrupt it. $lines = preg_split( "/\r\n(?![ \t])|\r(?![ \t])|\n(?![ \t])/", (string) $headers ); } $lines = is_array( $lines ) ? $lines : array(); $clean = array(); foreach ( $lines as $line ) { // rtrim only: leading whitespace on a folded continuation is // significant. An all-whitespace line is dropped. $line = rtrim( (string) $line, "\r\n" ); if ( '' !== trim( $line ) ) { $clean[] = $line; } } $has_from = false; $has_content_type = false; foreach ( $clean as $line ) { $lower = strtolower( $line ); if ( 0 === strpos( $lower, 'from:' ) ) { $has_from = true; } if ( 0 === strpos( $lower, 'content-type:' ) ) { $has_content_type = true; } } if ( ! $has_from && is_email( $from_address ) ) { $name = trim( str_replace( array( '"', "\r", "\n" ), '', (string) $from_name ) ); if ( '' !== $name ) { array_unshift( $clean, sprintf( 'From: "%s" <%s>', $name, $from_address ) ); } else { array_unshift( $clean, sprintf( 'From: %s', $from_address ) ); } } if ( ! $has_content_type && is_string( $content_type ) && '' !== $content_type ) { $clean[] = 'Content-Type: ' . $content_type; } return $was_array ? $clean : implode( "\r\n", $clean ); } }