| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280 |
- <?php
- /**
- * pre_wp_mail interception.
- *
- * @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_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 );
- }
- }
|