class-wcmq-interceptor.php 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280
  1. <?php
  2. /**
  3. * pre_wp_mail interception.
  4. *
  5. * @package studiou-wc-mail-queue
  6. * @copyright 2026 QUADARAX
  7. * @author Dalibor Votruba <dvotruba@quadarax.com>
  8. * @license GPL-2.0-or-later
  9. */
  10. defined( 'ABSPATH' ) || exit;
  11. /**
  12. * Class Studiou_WCMQ_Interceptor
  13. */
  14. class Studiou_WCMQ_Interceptor {
  15. /**
  16. * True while the worker is calling wp_mail() to actually send.
  17. *
  18. * Always cleared in a `finally`. A stuck `true` under Action Scheduler means
  19. * every later send in the batch bypasses the queue.
  20. *
  21. * @var bool
  22. */
  23. public static $sending = false;
  24. /**
  25. * Register hooks.
  26. */
  27. public static function init() {
  28. add_filter( 'pre_wp_mail', array( __CLASS__, 'maybe_enqueue' ), 10, 2 );
  29. // WooCommerce 10.9's EmailLogger listens on woocommerce_email_sent, which
  30. // fires with $success = true the moment we short-circuit wp_mail(). Left
  31. // alone it writes an order note and a `transactional-emails` log line
  32. // claiming the mail was sent, up to (interval x queue depth) early — and
  33. // never reconciles it if the deferred send later fails permanently.
  34. //
  35. // Suppress on "was THIS mail queued", never on "is this email type
  36. // handled": a handled type still sends synchronously while draining, or
  37. // when Action Scheduler is missing, or when enqueue failed. In those
  38. // cases the note is correct and no worker will write a replacement.
  39. add_filter( 'woocommerce_email_log_enabled', array( __CLASS__, 'suppress_email_log' ), 10, 2 );
  40. add_filter( 'woocommerce_email_log_add_order_note', array( __CLASS__, 'suppress_email_log' ), 10, 2 );
  41. }
  42. /**
  43. * Suppress EmailLogger for the one mail we queued — and nothing else.
  44. *
  45. * Matching on the flag alone is not enough. `woocommerce_email_log_enabled`
  46. * is also applied from EmailLogger::log_non_send_outcome(), which runs on
  47. * `woocommerce_email_disabled` and `woocommerce_email_skipped`. Both of those
  48. * fire from WC_Email::send_notification() *before* send() is ever called, so
  49. * the probe never runs and never resets the flag.
  50. *
  51. * Concretely, on a stock shop: pending -> processing fires the customer
  52. * processing email (queued, flag set) and then the admin new-order email. If
  53. * the admin disabled that one — routine — we would silently swallow
  54. * WooCommerce's "not sent: email type is disabled" log line for it.
  55. *
  56. * Match the email id too, so only the mail we actually queued is suppressed.
  57. *
  58. * @param bool $enabled Current value.
  59. * @param string $email_id WC_Email id this filter is being applied for.
  60. * @return bool
  61. */
  62. public static function suppress_email_log( $enabled, $email_id = '' ) {
  63. if ( ! Studiou_WCMQ_Context::was_queued() ) {
  64. return $enabled;
  65. }
  66. if ( (string) $email_id !== Studiou_WCMQ_Context::queued_context_id() ) {
  67. return $enabled;
  68. }
  69. return false;
  70. }
  71. /**
  72. * Short-circuit wp_mail() for handled WooCommerce email, or pass through.
  73. *
  74. * Returning true tells WordPress the mail was sent. We only ever do that
  75. * once the row and its Action Scheduler action are durably persisted.
  76. * Every failure path returns null so wp_mail() proceeds and the mail sends
  77. * synchronously — an unthrottled send beats a silently lost one.
  78. *
  79. * @param null|bool $short_circuit Null to continue.
  80. * @param array $atts wp_mail arguments.
  81. * @return null|bool
  82. */
  83. public static function maybe_enqueue( $short_circuit, $atts ) {
  84. if ( self::$sending ) {
  85. return $short_circuit;
  86. }
  87. // Consume the context FIRST, unconditionally — before the $short_circuit
  88. // check, before the $atts shape check. If any of those early-returns ran
  89. // before consume(), an in-flight context would survive onto the next
  90. // wp_mail() in the request, which is the stale-context bug §3.1 exists to
  91. // prevent. This is why we read to/subject defensively here rather than
  92. // gating on the isset() first.
  93. $to = is_array( $atts ) && isset( $atts['to'] ) ? $atts['to'] : null;
  94. $subject = is_array( $atts ) && isset( $atts['subject'] ) ? $atts['subject'] : null;
  95. $ctx = Studiou_WCMQ_Context::consume( $to, $subject );
  96. // WordPress threads each pre_wp_mail callback's return value into the
  97. // next. A non-null value means another plugin — typically a transactional
  98. // API mailer hooked at a lower priority — has ALREADY delivered (true) or
  99. // already failed (false) this message. Queueing it now would send it a
  100. // second time; returning true over their false would report a delivery
  101. // that never happened. Pass their verdict through untouched.
  102. if ( null !== $short_circuit ) {
  103. return $short_circuit;
  104. }
  105. if ( ! is_array( $atts ) || ! isset( $atts['to'], $atts['subject'], $atts['message'] ) ) {
  106. return $short_circuit;
  107. }
  108. // A WooCommerce email was in flight but its fingerprint did not match the
  109. // mail wp_mail() actually received — a wp_mail filter rewrote the
  110. // recipient or subject after we captured it. The mail is about to send
  111. // unthrottled. This is the difference between "nothing to do" and "the
  112. // plugin is being silently defeated", so make it visible.
  113. if ( isset( $ctx['status'] ) && 'mismatch' === $ctx['status'] ) {
  114. Studiou_WCMQ_DB::log(
  115. 'warning',
  116. sprintf(
  117. '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.',
  118. isset( $ctx['id'] ) ? $ctx['id'] : '?',
  119. isset( $ctx['differs'] ) ? $ctx['differs'] : 'message'
  120. )
  121. );
  122. return $short_circuit;
  123. }
  124. if ( ! isset( $ctx['status'] ) || 'ok' !== $ctx['status'] || '' === $ctx['id'] ) {
  125. return $short_circuit;
  126. }
  127. // From here we have a real, matched WooCommerce email. Log it before every
  128. // decision so "why didn't this get queued?" is answerable from the log
  129. // (Phase 3 acceptance): one line per handled email, naming its type.
  130. Studiou_WCMQ_DB::log( 'debug', sprintf( 'Saw WooCommerce email "%s".', $ctx['id'] ) );
  131. if ( ! Studiou_WCMQ_State::is_queueing() ) {
  132. return $short_circuit;
  133. }
  134. if ( ! Studiou_WCMQ_Settings::handles( $ctx['id'] ) ) {
  135. return $short_circuit;
  136. }
  137. if ( ! function_exists( 'as_schedule_single_action' ) ) {
  138. Studiou_WCMQ_DB::log( 'warning', 'Action Scheduler unavailable; sending synchronously.' );
  139. return $short_circuit;
  140. }
  141. $payload = self::snapshot( $atts );
  142. if ( null === $payload ) {
  143. return $short_circuit;
  144. }
  145. $row_id = Studiou_WCMQ_Queue::enqueue( $ctx, $payload );
  146. if ( ! $row_id ) {
  147. // Queue::enqueue() already logged and rolled back.
  148. return $short_circuit;
  149. }
  150. Studiou_WCMQ_Context::mark_queued( $row_id, $ctx['id'] );
  151. return true;
  152. }
  153. /**
  154. * Build a filter-independent snapshot of the mail.
  155. *
  156. * pre_wp_mail runs before wp_mail() applies wp_mail_from / wp_mail_from_name
  157. * / wp_mail_content_type, and WC_Email::send() detaches its own callbacks for
  158. * those the instant wp_mail() returns. A deferred send would therefore go out
  159. * as wordpress@{sitename}. Resolve them now, while WooCommerce's filters are
  160. * still attached, and bake the result into the stored headers.
  161. *
  162. * @param array $atts wp_mail arguments.
  163. * @return array|null
  164. */
  165. private static function snapshot( array $atts ) {
  166. $headers = isset( $atts['headers'] ) ? $atts['headers'] : '';
  167. $attachments = isset( $atts['attachments'] ) ? (array) $atts['attachments'] : array();
  168. // Mirror wp_mail()'s own default so WooCommerce's filter receives what it
  169. // expects. get_from_address() ignores the argument anyway.
  170. $sitename = wp_parse_url( network_home_url(), PHP_URL_HOST );
  171. $sitename = is_string( $sitename ) ? strtolower( $sitename ) : '';
  172. if ( 0 === strpos( $sitename, 'www.' ) ) {
  173. $sitename = substr( $sitename, 4 );
  174. }
  175. $default_from = 'wordpress@' . $sitename;
  176. /** This filter is documented in wp-includes/pluggable.php */
  177. $from_address = apply_filters( 'wp_mail_from', $default_from );
  178. /** This filter is documented in wp-includes/pluggable.php */
  179. $from_name = apply_filters( 'wp_mail_from_name', 'WordPress' );
  180. /** This filter is documented in wp-includes/pluggable.php */
  181. $content_type = apply_filters( 'wp_mail_content_type', 'text/plain' );
  182. return array(
  183. 'to' => $atts['to'],
  184. 'subject' => $atts['subject'],
  185. 'message' => $atts['message'],
  186. 'headers' => self::inject_headers( $headers, $from_address, $from_name, $content_type ),
  187. 'attachments' => $attachments,
  188. );
  189. }
  190. /**
  191. * Add From: (and Content-Type: if missing) to the captured headers.
  192. *
  193. * WC_Email::get_headers() returns a \r\n-delimited STRING carrying
  194. * Content-Type and Reply-to — and never a From. Non-WooCommerce callers may
  195. * pass an array, so handle both.
  196. *
  197. * @param string|array $headers Original headers.
  198. * @param string $from_address Resolved sender address.
  199. * @param string $from_name Resolved sender name.
  200. * @param string $content_type Resolved content type.
  201. * @return string|array
  202. */
  203. private static function inject_headers( $headers, $from_address, $from_name, $content_type ) {
  204. $was_array = is_array( $headers );
  205. if ( $was_array ) {
  206. $lines = $headers;
  207. } else {
  208. // Split on line breaks, but NOT on a folded-header continuation: RFC
  209. // 5322 allows a long header value to wrap onto a following line that
  210. // begins with whitespace. Splitting there and trimming each line would
  211. // turn the continuation into a standalone colon-less line, which
  212. // wp_mail() then discards. WooCommerce's own get_headers() never
  213. // folds, so this only bites a third-party woocommerce_email_headers
  214. // filter — but preserve the fold rather than corrupt it.
  215. $lines = preg_split( "/\r\n(?![ \t])|\r(?![ \t])|\n(?![ \t])/", (string) $headers );
  216. }
  217. $lines = is_array( $lines ) ? $lines : array();
  218. $clean = array();
  219. foreach ( $lines as $line ) {
  220. // rtrim only: leading whitespace on a folded continuation is
  221. // significant. An all-whitespace line is dropped.
  222. $line = rtrim( (string) $line, "\r\n" );
  223. if ( '' !== trim( $line ) ) {
  224. $clean[] = $line;
  225. }
  226. }
  227. $has_from = false;
  228. $has_content_type = false;
  229. foreach ( $clean as $line ) {
  230. $lower = strtolower( $line );
  231. if ( 0 === strpos( $lower, 'from:' ) ) {
  232. $has_from = true;
  233. }
  234. if ( 0 === strpos( $lower, 'content-type:' ) ) {
  235. $has_content_type = true;
  236. }
  237. }
  238. if ( ! $has_from && is_email( $from_address ) ) {
  239. $name = trim( str_replace( array( '"', "\r", "\n" ), '', (string) $from_name ) );
  240. if ( '' !== $name ) {
  241. array_unshift( $clean, sprintf( 'From: "%s" <%s>', $name, $from_address ) );
  242. } else {
  243. array_unshift( $clean, sprintf( 'From: %s', $from_address ) );
  244. }
  245. }
  246. if ( ! $has_content_type && is_string( $content_type ) && '' !== $content_type ) {
  247. $clean[] = 'Content-Type: ' . $content_type;
  248. }
  249. return $was_array ? $clean : implode( "\r\n", $clean );
  250. }
  251. }