| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- <?php
- /**
- * Request-scoped tag identifying which WooCommerce email is currently sending.
- *
- * `pre_wp_mail` receives only to/subject/message/headers/attachments — nothing
- * that identifies the WC_Email class. `WC_Email::send()` applies
- * `woocommerce_mail_callback` with $this as the second argument, immediately
- * before calling wp_mail(). That is the only hook that exposes the instance.
- *
- * Verified against WooCommerce 10.9.4, includes/emails/class-wc-email.php:1234.
- *
- * @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_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 = '';
- }
- }
|