* @license GPL-2.0-or-later */ defined( 'ABSPATH' ) || exit; /** * Class Studiou_WCMQ_Context */ class Studiou_WCMQ_Context { /** * Context for the send currently in progress. * * @var array|null */ private static $current = null; /** * Row id of the mail queued by the current send, or 0. * * @var int */ private static $queued_row_id = 0; /** * WC_Email id of the mail that was queued, or ''. * * The EmailLogger suppression filters must match on this. They fire for * emails that never reach send() at all — see suppress_email_log(). * * @var string */ private static $queued_context_id = ''; /** * Register hooks. */ public static function init() { add_filter( 'woocommerce_mail_callback', array( __CLASS__, 'probe_callback' ), 10, 2 ); add_filter( 'woocommerce_mail_callback_params', array( __CLASS__, 'probe_params' ), 10, 2 ); // These fire from send_notification() before send(), so they mean the // current email is NOT the one we queued. Clear the flag before // EmailLogger (priority 10) reads it, so its "not sent" log line for a // disabled/skipped same-id email is not suppressed. See clear_queued(). add_action( 'woocommerce_email_disabled', array( __CLASS__, 'clear_queued' ), 1 ); add_action( 'woocommerce_email_skipped', array( __CLASS__, 'clear_queued' ), 1 ); add_action( 'shutdown', array( __CLASS__, 'clear_all' ) ); } /** * Capture the email identity. Returns the callback untouched. * * Reading $email->id here (rather than caching it per instance) is required: * WC_Email_Customer_Refunded_Order::trigger() reassigns its own id to * 'customer_partially_refunded_order' before send() runs. * * $email->object is the only route to the order — pre_wp_mail cannot see it. * * @param callable $callback Mail callback ('wp_mail'). * @param mixed $email WC_Email instance. * @return callable */ public static function probe_callback( $callback, $email = null ) { // A new send is beginning: reset the was-queued flag for it. The flag is // NOT cleared after use, because woocommerce_email_sent (which reads it) // fires after wp_mail() has already returned. self::$queued_row_id = 0; self::$queued_context_id = ''; $id = ''; $order_id = 0; if ( is_object( $email ) ) { if ( isset( $email->id ) ) { $id = (string) $email->id; } // $email->object is a WC_Product for stock mail, an id/array for some // others, and null for a few. Only an order is useful to us. if ( isset( $email->object ) && $email->object instanceof WC_Order ) { $order_id = (int) $email->object->get_id(); } } self::$current = array( 'id' => $id, 'order_id' => $order_id, 'to' => null, 'subject' => null, ); return $callback; } /** * Capture the to/subject fingerprint. * * @param array $params [$to, $subject, $message, $headers, $attachments]. * @param mixed $email WC_Email instance. * @return array */ public static function probe_params( $params, $email = null ) { unset( $email ); if ( is_array( self::$current ) && is_array( $params ) && isset( $params[0], $params[1] ) ) { self::$current['to'] = $params[0]; self::$current['subject'] = $params[1]; } return $params; } /** * Consume the context, once, if it belongs to this wp_mail() call. * * The fingerprint check matters: a plugin that filters * woocommerce_mail_callback to return its own mailer means wp_mail() never * runs, our context is never consumed, and the next unrelated wp_mail() in * the request would otherwise inherit it — silently queueing, say, a * password reset as if it were an order email. * * Distinguishes three outcomes, because they mean different things to the * caller and the second must be visible to an admin: * * ['status' => 'none'] no WooCommerce email was in flight — pass through * silently, this is every password reset and every * non-WC wp_mail() on the site. * ['status' => 'mismatch'] a WC email WAS in flight but the fingerprint * rejected it. That means a wp_mail filter rewrote * the recipient or subject between our capture (at * woocommerce_mail_callback_params) and now — a * staging redirector, a BCC archiver. The mail then * sends unthrottled, and the admin needs to know the * plugin is being defeated, not left guessing why it * "does nothing". * ['status' => 'ok', ...] matched; the context fields are merged in. * * @param mixed $to wp_mail 'to'. * @param mixed $subject wp_mail 'subject'. * @return array */ public static function consume( $to, $subject ) { $ctx = self::$current; self::$current = null; // No WooCommerce email was mid-send, or the params probe never ran. if ( ! is_array( $ctx ) || null === $ctx['to'] || null === $ctx['subject'] ) { return array( 'status' => 'none' ); } $to_match = self::normalise_to( $ctx['to'] ) === self::normalise_to( $to ); // The subject reaching wp_mail() has already been through // wp_specialchars_decode(); we captured the same decoded value. $subject_match = (string) $ctx['subject'] === (string) $subject; if ( ! $to_match || ! $subject_match ) { return array( 'status' => 'mismatch', 'id' => $ctx['id'], 'differs' => ( ! $to_match && ! $subject_match ) ? 'recipient and subject' : ( ! $to_match ? 'recipient' : 'subject' ), ); } $ctx['status'] = 'ok'; return $ctx; } /** * Normalise a wp_mail recipient (string or array) for comparison. * * @param mixed $to Recipient. * @return string */ private static function normalise_to( $to ) { if ( is_array( $to ) ) { $to = implode( ',', $to ); } return strtolower( trim( (string) $to ) ); } /** * Record that the current send was queued rather than sent. * * @param int $row_id Queue row id. * @param string $context_id WC_Email id that was queued. */ public static function mark_queued( $row_id, $context_id ) { self::$queued_row_id = (int) $row_id; self::$queued_context_id = (string) $context_id; } /** * Whether the current send was queued. * * Read by the EmailLogger suppression filters on woocommerce_email_sent. * * @return bool */ public static function was_queued() { return self::$queued_row_id > 0; } /** * Queue row id of the current send, or 0. * * @return int */ public static function queued_row_id() { return self::$queued_row_id; } /** * WC_Email id of the mail that was queued, or ''. * * @return string */ public static function queued_context_id() { return self::$queued_context_id; } /** * Clear only the was-queued flag, leaving $current intact. * * Hooked to woocommerce_email_disabled / woocommerce_email_skipped, which * fire from WC_Email::send_notification() BEFORE send() — so the probe never * runs for them and never resets the flag. Without this, a second * same-id email in one request (e.g. two customer_completed_order sends, the * first queued, the second skipped for want of a billing address) would still * see was_queued() true with a matching id, and we would silently swallow * WooCommerce's "not sent" log line for the skipped one. * * @param mixed $arg1 First hook arg (email_id or reason); ignored. */ public static function clear_queued( $arg1 = null ) { unset( $arg1 ); self::$queued_row_id = 0; self::$queued_context_id = ''; } /** * Defensive end-of-request clear. */ public static function clear_all() { self::$current = null; self::$queued_row_id = 0; self::$queued_context_id = ''; } }