class-wcmq-notifier.php 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150
  1. <?php
  2. /**
  3. * Writes the order note and WC log line that EmailLogger was suppressed from
  4. * writing at enqueue time.
  5. *
  6. * This lives apart from the worker because it must be reachable from EVERY
  7. * terminal state, not just the two the worker owns. A queue row can reach
  8. * `sent` or `failed` from four places:
  9. *
  10. * Worker::on_success() mail went out
  11. * Worker::on_failure() max_attempts exhausted
  12. * Worker::send() payload was corrupt, unsendable
  13. * Reaper::recover_crashed() max_reclaims exhausted
  14. *
  15. * Miss any of them and the order carries no note and `transactional-emails`
  16. * carries no line: the customer never received the email and the shop's audit
  17. * trail says nothing happened at all. That is the same defect as a queue which
  18. * lies about delivery, inverted from a false positive into a silent hole.
  19. *
  20. * @package studiou-wc-mail-queue
  21. * @copyright 2026 QUADARAX
  22. * @author Dalibor Votruba <dvotruba@quadarax.com>
  23. * @license GPL-2.0-or-later
  24. */
  25. defined( 'ABSPATH' ) || exit;
  26. /**
  27. * Class Studiou_WCMQ_Notifier
  28. */
  29. class Studiou_WCMQ_Notifier {
  30. const LOG_SOURCE = 'transactional-emails';
  31. /**
  32. * Record the real outcome of a queued mail.
  33. *
  34. * Never throws: callers invoke this after the row has already been committed,
  35. * and a bookkeeping failure must not undo a send or re-open a resolved row.
  36. *
  37. * @param object $row Queue row.
  38. * @param bool $success Whether the mail went out.
  39. * @param string|null $error Error message, if any.
  40. */
  41. public static function annotate( $row, $success, $error = null ) {
  42. try {
  43. self::write( $row, (bool) $success, self::redact_emails( (string) $error ) );
  44. } catch ( Throwable $e ) {
  45. Studiou_WCMQ_DB::log( 'warning', 'Could not record mail outcome: ' . $e->getMessage(), (int) $row->id );
  46. }
  47. }
  48. /**
  49. * Order note + WC log line.
  50. *
  51. * @param object $row Queue row.
  52. * @param bool $success Outcome.
  53. * @param string $error Redacted error message.
  54. */
  55. private static function write( $row, $success, $error ) {
  56. $order_id = (int) $row->order_id;
  57. if ( $order_id > 0 ) {
  58. // order_id > 0 does not mean the order still exists. An order trashed
  59. // and emptied between enqueue and send makes wc_get_order() return
  60. // false, and false->add_order_note() is a fatal Error.
  61. $order = wc_get_order( $order_id );
  62. if ( $order instanceof WC_Order ) {
  63. if ( $success ) {
  64. /* translators: 1: email type, 2: recipient */
  65. $note = sprintf( __( 'Queued email "%1$s" sent to %2$s.', 'studiou-wc-mail-queue' ), $row->context, $row->recipient );
  66. } else {
  67. /* translators: 1: email type, 2: error message */
  68. $note = sprintf( __( 'Queued email "%1$s" failed to send: %2$s', 'studiou-wc-mail-queue' ), $row->context, $error );
  69. }
  70. // Groups with WooCommerce's own email notes in the order-notes UI.
  71. // The value is the literal behind OrderNoteGroup::EMAIL_NOTIFICATION;
  72. // that class is in the Internal\ namespace, so never depend on it.
  73. // The $meta_data parameter postdates our declared WC floor — PHP
  74. // drops extra arguments to a userland method, so an older signature
  75. // simply ignores it.
  76. $order->add_order_note( $note, 0, false, array( 'note_group' => 'email_notification' ) );
  77. }
  78. }
  79. if ( ! function_exists( 'wc_get_logger' ) ) {
  80. return;
  81. }
  82. // We suppressed EmailLogger for this mail, so we inherit its privacy
  83. // contract. The `transactional-emails` log is readable by anyone with
  84. // manage_woocommerce, and WooCommerce deliberately never writes raw
  85. // recipient addresses or raw PHPMailer error strings into it. The
  86. // plugin's own queue table holds the real address; this log must not.
  87. $context = array(
  88. 'source' => self::LOG_SOURCE,
  89. 'email_type' => $row->context,
  90. 'status' => $success ? 'sent' : 'failed',
  91. 'recipient' => self::resolve_recipient( (string) $row->recipient ),
  92. );
  93. if ( $order_id > 0 ) {
  94. $context['order'] = $order_id;
  95. }
  96. $logger = wc_get_logger();
  97. if ( $success ) {
  98. $logger->info( sprintf( 'Queued email "%s" sent.', $row->context ), $context );
  99. } else {
  100. $logger->error( sprintf( 'Queued email "%s" failed: %s', $row->context, $error ), $context );
  101. }
  102. }
  103. /**
  104. * Map recipient addresses to usernames, or 'guest'.
  105. *
  106. * Mirrors EmailLogger::resolve_recipient().
  107. *
  108. * @param string $recipient Comma-separated addresses.
  109. * @return string
  110. */
  111. public static function resolve_recipient( $recipient ) {
  112. if ( '' === $recipient ) {
  113. return 'guest';
  114. }
  115. $labels = array();
  116. foreach ( explode( ',', $recipient ) as $address ) {
  117. $user = get_user_by( 'email', trim( $address ) );
  118. $labels[] = $user instanceof WP_User ? $user->user_login : 'guest';
  119. }
  120. return implode( ', ', $labels );
  121. }
  122. /**
  123. * Strip email addresses out of a message.
  124. *
  125. * Mirrors EmailLogger::redact_emails(). PHPMailer error strings routinely
  126. * embed the recipient.
  127. *
  128. * @param string $message Message.
  129. * @return string
  130. */
  131. public static function redact_emails( $message ) {
  132. return (string) preg_replace(
  133. '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/',
  134. '[redacted_email]',
  135. $message
  136. );
  137. }
  138. }