class-wcmq-context.php 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. <?php
  2. /**
  3. * Request-scoped tag identifying which WooCommerce email is currently sending.
  4. *
  5. * `pre_wp_mail` receives only to/subject/message/headers/attachments — nothing
  6. * that identifies the WC_Email class. `WC_Email::send()` applies
  7. * `woocommerce_mail_callback` with $this as the second argument, immediately
  8. * before calling wp_mail(). That is the only hook that exposes the instance.
  9. *
  10. * Verified against WooCommerce 10.9.4, includes/emails/class-wc-email.php:1234.
  11. *
  12. * @package studiou-wc-mail-queue
  13. * @copyright 2026 QUADARAX
  14. * @author Dalibor Votruba <dvotruba@quadarax.com>
  15. * @license GPL-2.0-or-later
  16. */
  17. defined( 'ABSPATH' ) || exit;
  18. /**
  19. * Class Studiou_WCMQ_Context
  20. */
  21. class Studiou_WCMQ_Context {
  22. /**
  23. * Context for the send currently in progress.
  24. *
  25. * @var array|null
  26. */
  27. private static $current = null;
  28. /**
  29. * Row id of the mail queued by the current send, or 0.
  30. *
  31. * @var int
  32. */
  33. private static $queued_row_id = 0;
  34. /**
  35. * WC_Email id of the mail that was queued, or ''.
  36. *
  37. * The EmailLogger suppression filters must match on this. They fire for
  38. * emails that never reach send() at all — see suppress_email_log().
  39. *
  40. * @var string
  41. */
  42. private static $queued_context_id = '';
  43. /**
  44. * Register hooks.
  45. */
  46. public static function init() {
  47. add_filter( 'woocommerce_mail_callback', array( __CLASS__, 'probe_callback' ), 10, 2 );
  48. add_filter( 'woocommerce_mail_callback_params', array( __CLASS__, 'probe_params' ), 10, 2 );
  49. // These fire from send_notification() before send(), so they mean the
  50. // current email is NOT the one we queued. Clear the flag before
  51. // EmailLogger (priority 10) reads it, so its "not sent" log line for a
  52. // disabled/skipped same-id email is not suppressed. See clear_queued().
  53. add_action( 'woocommerce_email_disabled', array( __CLASS__, 'clear_queued' ), 1 );
  54. add_action( 'woocommerce_email_skipped', array( __CLASS__, 'clear_queued' ), 1 );
  55. add_action( 'shutdown', array( __CLASS__, 'clear_all' ) );
  56. }
  57. /**
  58. * Capture the email identity. Returns the callback untouched.
  59. *
  60. * Reading $email->id here (rather than caching it per instance) is required:
  61. * WC_Email_Customer_Refunded_Order::trigger() reassigns its own id to
  62. * 'customer_partially_refunded_order' before send() runs.
  63. *
  64. * $email->object is the only route to the order — pre_wp_mail cannot see it.
  65. *
  66. * @param callable $callback Mail callback ('wp_mail').
  67. * @param mixed $email WC_Email instance.
  68. * @return callable
  69. */
  70. public static function probe_callback( $callback, $email = null ) {
  71. // A new send is beginning: reset the was-queued flag for it. The flag is
  72. // NOT cleared after use, because woocommerce_email_sent (which reads it)
  73. // fires after wp_mail() has already returned.
  74. self::$queued_row_id = 0;
  75. self::$queued_context_id = '';
  76. $id = '';
  77. $order_id = 0;
  78. if ( is_object( $email ) ) {
  79. if ( isset( $email->id ) ) {
  80. $id = (string) $email->id;
  81. }
  82. // $email->object is a WC_Product for stock mail, an id/array for some
  83. // others, and null for a few. Only an order is useful to us.
  84. if ( isset( $email->object ) && $email->object instanceof WC_Order ) {
  85. $order_id = (int) $email->object->get_id();
  86. }
  87. }
  88. self::$current = array(
  89. 'id' => $id,
  90. 'order_id' => $order_id,
  91. 'to' => null,
  92. 'subject' => null,
  93. );
  94. return $callback;
  95. }
  96. /**
  97. * Capture the to/subject fingerprint.
  98. *
  99. * @param array $params [$to, $subject, $message, $headers, $attachments].
  100. * @param mixed $email WC_Email instance.
  101. * @return array
  102. */
  103. public static function probe_params( $params, $email = null ) {
  104. unset( $email );
  105. if ( is_array( self::$current ) && is_array( $params ) && isset( $params[0], $params[1] ) ) {
  106. self::$current['to'] = $params[0];
  107. self::$current['subject'] = $params[1];
  108. }
  109. return $params;
  110. }
  111. /**
  112. * Consume the context, once, if it belongs to this wp_mail() call.
  113. *
  114. * The fingerprint check matters: a plugin that filters
  115. * woocommerce_mail_callback to return its own mailer means wp_mail() never
  116. * runs, our context is never consumed, and the next unrelated wp_mail() in
  117. * the request would otherwise inherit it — silently queueing, say, a
  118. * password reset as if it were an order email.
  119. *
  120. * Distinguishes three outcomes, because they mean different things to the
  121. * caller and the second must be visible to an admin:
  122. *
  123. * ['status' => 'none'] no WooCommerce email was in flight — pass through
  124. * silently, this is every password reset and every
  125. * non-WC wp_mail() on the site.
  126. * ['status' => 'mismatch'] a WC email WAS in flight but the fingerprint
  127. * rejected it. That means a wp_mail filter rewrote
  128. * the recipient or subject between our capture (at
  129. * woocommerce_mail_callback_params) and now — a
  130. * staging redirector, a BCC archiver. The mail then
  131. * sends unthrottled, and the admin needs to know the
  132. * plugin is being defeated, not left guessing why it
  133. * "does nothing".
  134. * ['status' => 'ok', ...] matched; the context fields are merged in.
  135. *
  136. * @param mixed $to wp_mail 'to'.
  137. * @param mixed $subject wp_mail 'subject'.
  138. * @return array
  139. */
  140. public static function consume( $to, $subject ) {
  141. $ctx = self::$current;
  142. self::$current = null;
  143. // No WooCommerce email was mid-send, or the params probe never ran.
  144. if ( ! is_array( $ctx ) || null === $ctx['to'] || null === $ctx['subject'] ) {
  145. return array( 'status' => 'none' );
  146. }
  147. $to_match = self::normalise_to( $ctx['to'] ) === self::normalise_to( $to );
  148. // The subject reaching wp_mail() has already been through
  149. // wp_specialchars_decode(); we captured the same decoded value.
  150. $subject_match = (string) $ctx['subject'] === (string) $subject;
  151. if ( ! $to_match || ! $subject_match ) {
  152. return array(
  153. 'status' => 'mismatch',
  154. 'id' => $ctx['id'],
  155. 'differs' => ( ! $to_match && ! $subject_match ) ? 'recipient and subject'
  156. : ( ! $to_match ? 'recipient' : 'subject' ),
  157. );
  158. }
  159. $ctx['status'] = 'ok';
  160. return $ctx;
  161. }
  162. /**
  163. * Normalise a wp_mail recipient (string or array) for comparison.
  164. *
  165. * @param mixed $to Recipient.
  166. * @return string
  167. */
  168. private static function normalise_to( $to ) {
  169. if ( is_array( $to ) ) {
  170. $to = implode( ',', $to );
  171. }
  172. return strtolower( trim( (string) $to ) );
  173. }
  174. /**
  175. * Record that the current send was queued rather than sent.
  176. *
  177. * @param int $row_id Queue row id.
  178. * @param string $context_id WC_Email id that was queued.
  179. */
  180. public static function mark_queued( $row_id, $context_id ) {
  181. self::$queued_row_id = (int) $row_id;
  182. self::$queued_context_id = (string) $context_id;
  183. }
  184. /**
  185. * Whether the current send was queued.
  186. *
  187. * Read by the EmailLogger suppression filters on woocommerce_email_sent.
  188. *
  189. * @return bool
  190. */
  191. public static function was_queued() {
  192. return self::$queued_row_id > 0;
  193. }
  194. /**
  195. * Queue row id of the current send, or 0.
  196. *
  197. * @return int
  198. */
  199. public static function queued_row_id() {
  200. return self::$queued_row_id;
  201. }
  202. /**
  203. * WC_Email id of the mail that was queued, or ''.
  204. *
  205. * @return string
  206. */
  207. public static function queued_context_id() {
  208. return self::$queued_context_id;
  209. }
  210. /**
  211. * Clear only the was-queued flag, leaving $current intact.
  212. *
  213. * Hooked to woocommerce_email_disabled / woocommerce_email_skipped, which
  214. * fire from WC_Email::send_notification() BEFORE send() — so the probe never
  215. * runs for them and never resets the flag. Without this, a second
  216. * same-id email in one request (e.g. two customer_completed_order sends, the
  217. * first queued, the second skipped for want of a billing address) would still
  218. * see was_queued() true with a matching id, and we would silently swallow
  219. * WooCommerce's "not sent" log line for the skipped one.
  220. *
  221. * @param mixed $arg1 First hook arg (email_id or reason); ignored.
  222. */
  223. public static function clear_queued( $arg1 = null ) {
  224. unset( $arg1 );
  225. self::$queued_row_id = 0;
  226. self::$queued_context_id = '';
  227. }
  228. /**
  229. * Defensive end-of-request clear.
  230. */
  231. public static function clear_all() {
  232. self::$current = null;
  233. self::$queued_row_id = 0;
  234. self::$queued_context_id = '';
  235. }
  236. }