DeferredEmailQueue.php
4 weeks ago
EmailColors.php
11 months ago
EmailFont.php
1 year ago
EmailLogger.php
4 weeks ago
EmailStyleSync.php
11 months ago
OrderPriceFormatter.php
1 year ago
EmailLogger.php
395 lines
| 1 | <?php |
| 2 | declare( strict_types = 1 ); |
| 3 | |
| 4 | namespace Automattic\WooCommerce\Internal\Email; |
| 5 | |
| 6 | use Automattic\WooCommerce\Internal\Orders\OrderNoteGroup; |
| 7 | use Automattic\WooCommerce\Internal\RegisterHooksInterface; |
| 8 | use WC_Email; |
| 9 | use WC_Log_Levels; |
| 10 | use WC_Order; |
| 11 | use WC_Product; |
| 12 | use WP_Error; |
| 13 | use WP_User; |
| 14 | |
| 15 | /** |
| 16 | * Logs transactional email send attempts so store owners can inspect what WooCommerce attempted locally. |
| 17 | * |
| 18 | * Records are written to the WooCommerce logger under the `transactional-emails` source and include the email type, |
| 19 | * related object, recipient identifier, and the local send state. The recipient is logged as the WordPress username |
| 20 | * when the address is linked to an account, or as 'guest' for unrecognised addresses. Failure reasons are captured |
| 21 | * from wp_mail_failed. |
| 22 | * |
| 23 | * @since 10.9.0 |
| 24 | * @internal |
| 25 | */ |
| 26 | class EmailLogger implements RegisterHooksInterface { |
| 27 | |
| 28 | /** |
| 29 | * Logger source used for all email log entries. |
| 30 | */ |
| 31 | private const LOG_SOURCE = 'transactional-emails'; |
| 32 | |
| 33 | /** |
| 34 | * Holds the PHPMailer error message from the most recent failed wp_mail() call. |
| 35 | * |
| 36 | * @var string|null |
| 37 | */ |
| 38 | private ?string $last_mail_error = null; |
| 39 | |
| 40 | /** |
| 41 | * Register hooks. |
| 42 | * |
| 43 | * @return void |
| 44 | */ |
| 45 | public function register(): void { |
| 46 | add_action( 'wp_mail_failed', array( $this, 'capture_mail_error' ), 10, 1 ); |
| 47 | add_action( 'woocommerce_email_sent', array( $this, 'handle_woocommerce_email_sent' ), 10, 3 ); |
| 48 | add_action( 'woocommerce_email_disabled', array( $this, 'handle_woocommerce_email_disabled' ), 10, 2 ); |
| 49 | add_action( 'woocommerce_email_skipped', array( $this, 'handle_woocommerce_email_skipped' ), 10, 3 ); |
| 50 | } |
| 51 | |
| 52 | /** |
| 53 | * Capture the PHPMailer error from a failed wp_mail() call so it can be included in the log entry. |
| 54 | * |
| 55 | * Error attribution is best-effort: wp_mail_failed is a global hook, so any plugin's failed |
| 56 | * wp_mail() call will set $last_mail_error. The trailing edge is controlled — $last_mail_error |
| 57 | * is cleared immediately after each WooCommerce send — but the leading edge is unbounded: a |
| 58 | * non-WooCommerce wp_mail_failed fired before a WooCommerce send failure will be attributed |
| 59 | * to that WooCommerce send. This may produce misleading error reasons in stores where other |
| 60 | * plugins also call wp_mail(). |
| 61 | * |
| 62 | * @param WP_Error $error The error returned by wp_mail. |
| 63 | * @return void |
| 64 | */ |
| 65 | public function capture_mail_error( WP_Error $error ): void { |
| 66 | $this->last_mail_error = $error->get_error_message(); |
| 67 | } |
| 68 | |
| 69 | /** |
| 70 | * Handle the woocommerce_email_sent action. |
| 71 | * |
| 72 | * @param bool $success Whether the email was sent successfully. |
| 73 | * @param string $email_id The email type ID (e.g. `customer_processing_order`). |
| 74 | * @param WC_Email $email The WC_Email instance. |
| 75 | * @return void |
| 76 | */ |
| 77 | public function handle_woocommerce_email_sent( $success, string $email_id, WC_Email $email ): void { |
| 78 | /** |
| 79 | * Filter whether to log this transactional email attempt. |
| 80 | * |
| 81 | * Return false to skip logging for a particular email or globally. |
| 82 | * |
| 83 | * @since 10.9.0 |
| 84 | * |
| 85 | * @param bool $enabled Whether logging is enabled. |
| 86 | * @param string $email_id The email type ID. |
| 87 | * @param WC_Email $email The WC_Email instance. |
| 88 | */ |
| 89 | if ( ! apply_filters( 'woocommerce_email_log_enabled', true, $email_id, $email ) ) { |
| 90 | $this->last_mail_error = null; |
| 91 | return; |
| 92 | } |
| 93 | |
| 94 | $object_context = $this->get_object_context( $email->object ); |
| 95 | $object_label = isset( $object_context['type'], $object_context['id'] ) |
| 96 | ? sprintf( ' for %s #%d', $object_context['type'], $object_context['id'] ) |
| 97 | : ''; |
| 98 | $last_mail_error = $this->last_mail_error; |
| 99 | |
| 100 | $this->last_mail_error = null; |
| 101 | |
| 102 | $context = array( |
| 103 | 'source' => self::LOG_SOURCE, |
| 104 | 'email_type' => $email_id, |
| 105 | 'status' => $success ? 'sent' : 'failed', |
| 106 | 'recipient' => $this->resolve_recipient( $email->get_recipient() ), |
| 107 | ); |
| 108 | |
| 109 | if ( ! empty( $object_context ) ) { |
| 110 | $context[ $object_context['type'] ] = $object_context['id'] ?? null; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Filter the context array logged for each transactional email attempt. |
| 115 | * |
| 116 | * @since 10.9.0 |
| 117 | * |
| 118 | * @param array $context The context array to be logged. |
| 119 | * @param string $email_id The email type ID. |
| 120 | * @param WC_Email $email The WC_Email instance. |
| 121 | */ |
| 122 | $context = (array) apply_filters( 'woocommerce_email_log_context', $context, $email_id, $email ); |
| 123 | |
| 124 | $type_label = ! empty( $context['is_test'] ) ? 'Test email' : 'Email'; |
| 125 | |
| 126 | if ( $success ) { |
| 127 | $message = sprintf( '%s "%s"%s sent', $type_label, $email_id, $object_label ); |
| 128 | } else { |
| 129 | $reason = $last_mail_error ? ': ' . $this->redact_emails( $last_mail_error ) : ''; |
| 130 | $message = sprintf( '%s "%s"%s failed to send%s', $type_label, $email_id, $object_label, $reason ); |
| 131 | } |
| 132 | |
| 133 | $level = $success ? WC_Log_Levels::INFO : WC_Log_Levels::WARNING; |
| 134 | wc_get_logger()->log( $level, $message, $context ); |
| 135 | |
| 136 | $this->maybe_add_order_note( $email->object, $email_id, $email, (bool) $success, $last_mail_error ); |
| 137 | } |
| 138 | |
| 139 | /** |
| 140 | * Add a private order note when a transactional email is sent or fails for an order. |
| 141 | * |
| 142 | * Accepts mixed input because $email->object is loosely typed (any object the email subclass attaches), |
| 143 | * and we narrow to WC_Order at the top of the method before doing anything with it. |
| 144 | * |
| 145 | * @param mixed $wc_object The email's related object, or false/null when none is set. |
| 146 | * @param string $email_id The email type ID (e.g. `customer_processing_order`). |
| 147 | * @param WC_Email $email The WC_Email instance. |
| 148 | * @param bool $success Whether the email was sent successfully. |
| 149 | * @param string|null $error_reason The error message from wp_mail_failed, or null. |
| 150 | * @return void |
| 151 | */ |
| 152 | private function maybe_add_order_note( $wc_object, string $email_id, WC_Email $email, bool $success, ?string $error_reason ): void { |
| 153 | if ( ! $wc_object instanceof WC_Order || ! $wc_object->get_object_read() ) { |
| 154 | return; |
| 155 | } |
| 156 | |
| 157 | /** |
| 158 | * Filter whether to add an order note for this transactional email attempt. |
| 159 | * |
| 160 | * Return false to suppress the order note for a particular email or globally, |
| 161 | * while still allowing the WooCommerce logger entry to be written. |
| 162 | * |
| 163 | * @since 10.9.0 |
| 164 | * |
| 165 | * @param bool $enabled Whether to add the order note. |
| 166 | * @param string $email_id The email type ID. |
| 167 | * @param WC_Email $email The WC_Email instance. |
| 168 | * @param WC_Order $order The order the note would be added to. |
| 169 | */ |
| 170 | if ( ! apply_filters( 'woocommerce_email_log_add_order_note', true, $email_id, $email, $wc_object ) ) { |
| 171 | return; |
| 172 | } |
| 173 | |
| 174 | $email_title = $email->get_title(); |
| 175 | $email_label = '' !== $email_title ? $email_title : $email_id; |
| 176 | |
| 177 | if ( $success ) { |
| 178 | $note = sprintf( |
| 179 | /* translators: %s: Email title or type identifier */ |
| 180 | __( 'Email "%s" sent.', 'woocommerce' ), |
| 181 | $email_label |
| 182 | ); |
| 183 | } elseif ( $error_reason ) { |
| 184 | $note = sprintf( |
| 185 | /* translators: 1: Email title or type identifier, 2: Error reason */ |
| 186 | __( 'Email "%1$s" failed to send: %2$s.', 'woocommerce' ), |
| 187 | $email_label, |
| 188 | $this->redact_emails( $error_reason ) |
| 189 | ); |
| 190 | } else { |
| 191 | $note = sprintf( |
| 192 | /* translators: %s: Email title or type identifier */ |
| 193 | __( 'Email "%s" failed to send.', 'woocommerce' ), |
| 194 | $email_label |
| 195 | ); |
| 196 | } |
| 197 | |
| 198 | $wc_object->add_order_note( $note, 0, false, array( 'note_group' => OrderNoteGroup::EMAIL_NOTIFICATION ) ); |
| 199 | } |
| 200 | |
| 201 | /** |
| 202 | * Handle the woocommerce_email_disabled action. |
| 203 | * |
| 204 | * @param string $email_id The email type ID (e.g. `customer_processing_order`). |
| 205 | * @param WC_Email $email The WC_Email instance. |
| 206 | * @return void |
| 207 | */ |
| 208 | public function handle_woocommerce_email_disabled( string $email_id, WC_Email $email ): void { |
| 209 | $this->log_non_send_outcome( $email_id, $email, 'disabled' ); |
| 210 | } |
| 211 | |
| 212 | /** |
| 213 | * Handle the woocommerce_email_skipped action. |
| 214 | * |
| 215 | * @param string $reason Short identifier for why the email was skipped (e.g. 'no_recipient'). |
| 216 | * @param string $email_id The email type ID (e.g. `new_order`). |
| 217 | * @param WC_Email $email The WC_Email instance. |
| 218 | * @return void |
| 219 | */ |
| 220 | public function handle_woocommerce_email_skipped( string $reason, string $email_id, WC_Email $email ): void { |
| 221 | $this->log_non_send_outcome( $email_id, $email, 'skipped', $reason ); |
| 222 | } |
| 223 | |
| 224 | /** |
| 225 | * Write a log entry for an email that was not sent (disabled or skipped). |
| 226 | * |
| 227 | * Centralises the shared logic for disabled and skipped outcomes so that the context |
| 228 | * schema (`source`, `email_type`, `status`, `reason`, `recipient`, object key) is |
| 229 | * defined in exactly one place. Future additions (e.g. a `correlation_id` field) only |
| 230 | * need to be made here. |
| 231 | * |
| 232 | * @param string $email_id The email type ID. |
| 233 | * @param WC_Email $email The WC_Email instance. |
| 234 | * @param string $status The outcome status: 'disabled' or 'skipped'. |
| 235 | * @param string|null $reason Optional reason identifier (only set for 'skipped' status). |
| 236 | * @return void |
| 237 | */ |
| 238 | private function log_non_send_outcome( string $email_id, WC_Email $email, string $status, ?string $reason = null ): void { |
| 239 | /** |
| 240 | * Filter whether to log this transactional email attempt. |
| 241 | * |
| 242 | * This filter is documented in src/Internal/Email/EmailLogger.php |
| 243 | * |
| 244 | * @since 10.9.0 |
| 245 | */ |
| 246 | if ( ! apply_filters( 'woocommerce_email_log_enabled', true, $email_id, $email ) ) { |
| 247 | return; |
| 248 | } |
| 249 | |
| 250 | $object_context = $this->get_object_context( $email->object ); |
| 251 | $object_label = isset( $object_context['type'], $object_context['id'] ) |
| 252 | ? sprintf( ' for %s #%d', $object_context['type'], $object_context['id'] ) |
| 253 | : ''; |
| 254 | |
| 255 | if ( 'disabled' === $status ) { |
| 256 | $message = sprintf( 'Email "%s"%s not sent: email type is disabled', $email_id, $object_label ); |
| 257 | } else { |
| 258 | $message = sprintf( 'Email "%s"%s not sent: %s', $email_id, $object_label, $reason ); |
| 259 | } |
| 260 | |
| 261 | $context = array( |
| 262 | 'source' => self::LOG_SOURCE, |
| 263 | 'email_type' => $email_id, |
| 264 | 'status' => $status, |
| 265 | 'recipient' => $this->resolve_recipient( $email->get_recipient() ), |
| 266 | ); |
| 267 | |
| 268 | if ( null !== $reason ) { |
| 269 | $context['reason'] = $reason; |
| 270 | } |
| 271 | |
| 272 | if ( ! empty( $object_context ) ) { |
| 273 | $context[ $object_context['type'] ] = $object_context['id'] ?? null; |
| 274 | } |
| 275 | |
| 276 | /** |
| 277 | * Filter the context array logged for each transactional email attempt. |
| 278 | * |
| 279 | * This filter is documented in src/Internal/Email/EmailLogger.php |
| 280 | * |
| 281 | * @since 10.9.0 |
| 282 | */ |
| 283 | $context = (array) apply_filters( 'woocommerce_email_log_context', $context, $email_id, $email ); |
| 284 | |
| 285 | wc_get_logger()->log( WC_Log_Levels::NOTICE, $message, $context ); |
| 286 | } |
| 287 | |
| 288 | /** |
| 289 | * Resolve a recipient email string to an identifier safe for logging. |
| 290 | * |
| 291 | * Each address is mapped to the corresponding WordPress username when an account |
| 292 | * exists, or to the string 'guest' for addresses with no associated account. |
| 293 | * This avoids storing plain email addresses in logs while still giving support |
| 294 | * teams a useful identifier for troubleshooting. |
| 295 | * |
| 296 | * @param string $recipient Comma-separated recipient email string from WC_Email::get_recipient(). |
| 297 | * @return string Comma-separated usernames or 'guest' labels. |
| 298 | */ |
| 299 | private function resolve_recipient( string $recipient ): string { |
| 300 | if ( '' === $recipient ) { |
| 301 | return 'guest'; |
| 302 | } |
| 303 | |
| 304 | $labels = array_map( |
| 305 | function ( string $email ): string { |
| 306 | $user = get_user_by( 'email', trim( $email ) ); |
| 307 | return $user instanceof WP_User ? $user->user_login : 'guest'; |
| 308 | }, |
| 309 | explode( ',', $recipient ) |
| 310 | ); |
| 311 | |
| 312 | return implode( ', ', $labels ); |
| 313 | } |
| 314 | |
| 315 | /** |
| 316 | * Replace any email addresses in a log message fragment with `[redacted_email]`. |
| 317 | * |
| 318 | * PHPMailer / SMTP error strings frequently embed the recipient address |
| 319 | * (e.g. "SMTP Error: Could not send to foo@example.com"). Without redaction, |
| 320 | * the address would be written into the log message and — when the database |
| 321 | * log handler is active — surface in WC > Status > Logs to anyone with |
| 322 | * `manage_woocommerce`, defeating the username/`guest` resolution applied |
| 323 | * to the `recipient` context field. |
| 324 | * |
| 325 | * Mirrors the regex used by RemoteLogger::redact_user_data() so the privacy |
| 326 | * posture stays consistent across loggers. |
| 327 | * |
| 328 | * @param string $message The message fragment to scrub. |
| 329 | * @return string The fragment with any email addresses replaced. |
| 330 | */ |
| 331 | private function redact_emails( string $message ): string { |
| 332 | return (string) preg_replace( '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', '[redacted_email]', $message ); |
| 333 | } |
| 334 | |
| 335 | /** |
| 336 | * Extract loggable context from the WooCommerce object attached to the email. |
| 337 | * |
| 338 | * Returns a stable short type identifier rather than the raw class name so that log aggregation |
| 339 | * is not brittle across subclasses (e.g. WC_Order_Refund still returns type 'order'). |
| 340 | * |
| 341 | * @param mixed $wc_object The email's related object (WC_Order, WC_Product, WP_User, etc.) or false/null. |
| 342 | * @return array{type: string, id?: int}|array{} Type and (when resolvable) ID of the object, or empty when no object is set. |
| 343 | */ |
| 344 | private function get_object_context( $wc_object ): array { |
| 345 | if ( ! is_object( $wc_object ) ) { |
| 346 | return array(); |
| 347 | } |
| 348 | |
| 349 | if ( $wc_object instanceof WC_Order ) { |
| 350 | $type = 'order'; |
| 351 | } elseif ( $wc_object instanceof WC_Product ) { |
| 352 | $type = 'product'; |
| 353 | } elseif ( $wc_object instanceof WP_User ) { |
| 354 | $type = 'user'; |
| 355 | } else { |
| 356 | $type = get_class( $wc_object ); |
| 357 | } |
| 358 | |
| 359 | $id = null; |
| 360 | if ( $wc_object instanceof WC_Order || $wc_object instanceof WC_Product ) { |
| 361 | // Both have an explicit get_id() — safe to call directly. |
| 362 | $id = (int) $wc_object->get_id(); |
| 363 | } elseif ( $wc_object instanceof WP_User ) { |
| 364 | // WP_User has no get_id() method; __call() returns false for unknown methods, |
| 365 | // which casts to 0 and bypasses the ID-property fallback below. |
| 366 | $id = (int) $wc_object->ID; |
| 367 | } elseif ( method_exists( $wc_object, 'get_id' ) ) { |
| 368 | try { |
| 369 | $method = new \ReflectionMethod( $wc_object, 'get_id' ); |
| 370 | if ( 0 === $method->getNumberOfRequiredParameters() ) { |
| 371 | $id = (int) $wc_object->get_id(); |
| 372 | } |
| 373 | } catch ( \Throwable $e ) { |
| 374 | $id = null; |
| 375 | } |
| 376 | } |
| 377 | |
| 378 | if ( null === $id ) { |
| 379 | $public_props = get_object_vars( $wc_object ); |
| 380 | if ( array_key_exists( 'ID', $public_props ) ) { |
| 381 | $id = (int) $public_props['ID']; |
| 382 | } |
| 383 | } |
| 384 | |
| 385 | if ( null === $id ) { |
| 386 | return array( 'type' => $type ); |
| 387 | } |
| 388 | |
| 389 | return array( |
| 390 | 'type' => $type, |
| 391 | 'id' => $id, |
| 392 | ); |
| 393 | } |
| 394 | } |
| 395 |