'skipped', $reason ); } /** * Write a log entry for an email that was not sent (disabled or skipped). * * Centralises the shared logic for disabled and skipped outcomes so that the context * schema (`source`, `email_type`, `status`, `reason`, `recipient`, object key) is * defined in exactly one place. Future additions (e.g. a `correlation_id` field) only * need to be made here. * * @param string $email_id The email type ID. * @param WC_Email $email The WC_Email instance. * @param string $status The outcome status: 'disabled' or 'skipped'. * @param string|null $reason Optional reason identifier (only set for 'skipped' status). * @return void */ private function log_non_send_outcome( string $email_id, WC_Email $email, string $status, ?string $reason = null ): void { /** * Filter whether to log this transactional email attempt. * * This filter is documented in src/Internal/Email/EmailLogger.php * * @since 10.9.0 */ if ( ! apply_filters( 'woocommerce_email_log_enabled', true, $email_id, $email ) ) { return; } $object_context = $this->get_object_context( $email->object ); $object_label = isset( $object_context['type'], $object_context['id'] ) ? sprintf( ' for %s #%d', $object_context['type'], $object_context['id'] ) : ''; if ( 'disabled' === $status ) { $message = sprintf( 'Email "%s"%s not sent: email type is disabled', $email_id, $object_label ); } else { $message = sprintf( 'Email "%s"%s not sent: %s', $email_id, $object_label, $reason ); } $context = array( 'source' => self::LOG_SOURCE, 'email_type' => $email_id, 'status' => $status, 'recipient' => $this->resolve_recipient( $email->get_recipient() ), ); if ( null !== $reason ) { $context['reason'] = $reason; } if ( ! empty( $object_context ) ) { $context[ $object_context['type'] ] = $object_context['id'] ?? null; } /** * Filter the context array logged for each transactional email attempt. * * This filter is documented in src/Internal/Email/EmailLogger.php * * @since 10.9.0 */ $context = (array) apply_filters( 'woocommerce_email_log_context', $context, $email_id, $email ); wc_get_logger()->log( WC_Log_Levels::NOTICE, $message, $context ); } /** * Resolve a recipient email string to an identifier safe for logging. * * Each address is mapped to the corresponding WordPress username when an account * exists, or to the string 'guest' for addresses with no associated account. * This avoids storing plain email addresses in logs while still giving support * teams a useful identifier for troubleshooting. * * @param string $recipient Comma-separated recipient email string from WC_Email::get_recipient(). * @return string Comma-separated usernames or 'guest' labels. */ private function resolve_recipient( string $recipient ): string { if ( '' === $recipient ) { return 'guest'; } $labels = array_map( function ( string $email ): string { $user = get_user_by( 'email', trim( $email ) ); return $user instanceof WP_User ? $user->user_login : 'guest'; }, explode( ',', $recipient ) ); return implode( ', ', $labels ); } /** * Replace any email addresses in a log message fragment with `[redacted_email]`. * * PHPMailer / SMTP error strings frequently embed the recipient address * (e.g. "SMTP Error: Could not send to foo@example.com"). Without redaction, * the address would be written into the log message and — when the database * log handler is active — surface in WC > Status > Logs to anyone with * `manage_woocommerce`, defeating the username/`guest` resolution applied * to the `recipient` context field. * * Mirrors the regex used by RemoteLogger::redact_user_data() so the privacy * posture stays consistent across loggers. * * @param string $message The message fragment to scrub. * @return string The fragment with any email addresses replaced. */ private function redact_emails( string $message ): string { return (string) preg_replace( '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', '[redacted_email]', $message ); } /** * Extract loggable context from the WooCommerce object attached to the email. * * Returns a stable short type identifier rather than the raw class name so that log aggregation * is not brittle across subclasses (e.g. WC_Order_Refund still returns type 'order'). * * @param mixed $wc_object The email's related object (WC_Order, WC_Product, WP_User, etc.) or false/null. * @return array{type: string, id?: int}|array{} Type and (when resolvable) ID of the object, or empty when no object is set. */ private function get_object_context( $wc_object ): array { if ( ! is_object( $wc_object ) ) { return array(); } if ( $wc_object instanceof WC_Order ) { $type = 'order'; } elseif ( $wc_object instanceof WC_Product ) { $type = 'product'; } elseif ( $wc_object instanceof WP_User ) { $type = 'user'; } else { $type = get_class( $wc_object ); } $id = null; if ( $wc_object instanceof WC_Order || $wc_object instanceof WC_Product ) { // Both have an explicit get_id() — safe to call directly. $id = (int) $wc_object->get_id(); } elseif ( $wc_object instanceof WP_User ) { // WP_User has no get_id() method; __call() returns false for unknown methods, // which casts to 0 and bypasses the ID-property fallback below. $id = (int) $wc_object->ID; } elseif ( method_exists( $wc_object, 'get_id' ) ) { try { $method = new \ReflectionMethod( $wc_object, 'get_id' ); if ( 0 === $method->getNumberOfRequiredParameters() ) { $id = (int) $wc_object->get_id(); } } catch ( \Throwable $e ) { $id = null; } } if ( null === $id ) { $public_props = get_object_vars( $wc_object ); if ( array_key_exists( 'ID', $public_props ) ) { $id = (int) $public_props['ID']; } } if ( null === $id ) { return array( 'type' => $type ); } return array( 'type' => $type, 'id' => $id, ); } }