Automattic\WooCommerce\Internal\Email

EmailLogger{}WC 10.9.0└─ RegisterHooksInterface

Logs transactional email send attempts so store owners can inspect what WooCommerce attempted locally.

Records are written to the WooCommerce logger under the transactional-emails source and include the email type, related object, recipient identifier, and the local send state. The recipient is logged as the WordPress username when the address is linked to an account, or as 'guest' for unrecognised addresses. Failure reasons are captured from wp_mail_failed.

Usage

$EmailLogger = new EmailLogger();
// use class methods

Methods

  1. public capture_mail_error( WP_Error $error )
  2. public handle_woocommerce_email_disabled( string $email_id, WC_Email $email )
  3. public handle_woocommerce_email_sent( $success, string $email_id, WC_Email $email )
  4. public handle_woocommerce_email_skipped( string $reason, string $email_id, WC_Email $email )
  5. public register()
  6. private get_object_context( $wc_object )
  7. private log_non_send_outcome( string $email_id, WC_Email $email, string $status, ?string $reason = null )
  8. private maybe_add_order_note( $wc_object, string $email_id, WC_Email $email, bool $success, ?string $error_reason )
  9. private redact_emails( string $message )
  10. private resolve_recipient( string $recipient )

Changelog

Since 10.9.0 Introduced.

EmailLogger{} code WC 10.9.1

class EmailLogger implements RegisterHooksInterface {

	/**
	 * Logger source used for all email log entries.
	 */
	private const LOG_SOURCE = 'transactional-emails';

	/**
	 * Holds the PHPMailer error message from the most recent failed wp_mail() call.
	 *
	 * @var string|null
	 */
	private ?string $last_mail_error = null;

	/**
	 * Register hooks.
	 *
	 * @return void
	 */
	public function register(): void {
		add_action( 'wp_mail_failed', array( $this, 'capture_mail_error' ), 10, 1 );
		add_action( 'woocommerce_email_sent', array( $this, 'handle_woocommerce_email_sent' ), 10, 3 );
		add_action( 'woocommerce_email_disabled', array( $this, 'handle_woocommerce_email_disabled' ), 10, 2 );
		add_action( 'woocommerce_email_skipped', array( $this, 'handle_woocommerce_email_skipped' ), 10, 3 );
	}

	/**
	 * Capture the PHPMailer error from a failed wp_mail() call so it can be included in the log entry.
	 *
	 * Error attribution is best-effort: wp_mail_failed is a global hook, so any plugin's failed
	 * wp_mail() call will set $last_mail_error. The trailing edge is controlled — $last_mail_error
	 * is cleared immediately after each WooCommerce send — but the leading edge is unbounded: a
	 * non-WooCommerce wp_mail_failed fired before a WooCommerce send failure will be attributed
	 * to that WooCommerce send. This may produce misleading error reasons in stores where other
	 * plugins also call wp_mail().
	 *
	 * @param WP_Error $error The error returned by wp_mail.
	 * @return void
	 */
	public function capture_mail_error( WP_Error $error ): void {
		$this->last_mail_error = $error->get_error_message();
	}

	/**
	 * Handle the woocommerce_email_sent action.
	 *
	 * @param bool     $success  Whether the email was sent successfully.
	 * @param string   $email_id The email type ID (e.g. `customer_processing_order`).
	 * @param WC_Email $email    The WC_Email instance.
	 * @return void
	 */
	public function handle_woocommerce_email_sent( $success, string $email_id, WC_Email $email ): void {
		/**
		 * Filter whether to log this transactional email attempt.
		 *
		 * Return false to skip logging for a particular email or globally.
		 *
		 * @since 10.9.0
		 *
		 * @param bool     $enabled  Whether logging is enabled.
		 * @param string   $email_id The email type ID.
		 * @param WC_Email $email    The WC_Email instance.
		 */
		if ( ! apply_filters( 'woocommerce_email_log_enabled', true, $email_id, $email ) ) {
			$this->last_mail_error = null;
			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'] )
			: '';
		$last_mail_error = $this->last_mail_error;

		$this->last_mail_error = null;

		$context = array(
			'source'     => self::LOG_SOURCE,
			'email_type' => $email_id,
			'status'     => $success ? 'sent' : 'failed',
			'recipient'  => $this->resolve_recipient( $email->get_recipient() ),
		);

		if ( ! empty( $object_context ) ) {
			$context[ $object_context['type'] ] = $object_context['id'] ?? null;
		}

		/**
		 * Filter the context array logged for each transactional email attempt.
		 *
		 * @since 10.9.0
		 *
		 * @param array    $context  The context array to be logged.
		 * @param string   $email_id The email type ID.
		 * @param WC_Email $email    The WC_Email instance.
		 */
		$context = (array) apply_filters( 'woocommerce_email_log_context', $context, $email_id, $email );

		$type_label = ! empty( $context['is_test'] ) ? 'Test email' : 'Email';

		if ( $success ) {
			$message = sprintf( '%s "%s"%s sent', $type_label, $email_id, $object_label );
		} else {
			$reason  = $last_mail_error ? ': ' . $this->redact_emails( $last_mail_error ) : '';
			$message = sprintf( '%s "%s"%s failed to send%s', $type_label, $email_id, $object_label, $reason );
		}

		$level = $success ? WC_Log_Levels::INFO : WC_Log_Levels::WARNING;
		wc_get_logger()->log( $level, $message, $context );

		$this->maybe_add_order_note( $email->object, $email_id, $email, (bool) $success, $last_mail_error );
	}

	/**
	 * Add a private order note when a transactional email is sent or fails for an order.
	 *
	 * Accepts mixed input because $email->object is loosely typed (any object the email subclass attaches),
	 * and we narrow to WC_Order at the top of the method before doing anything with it.
	 *
	 * @param mixed       $wc_object    The email's related object, or false/null when none is set.
	 * @param string      $email_id     The email type ID (e.g. `customer_processing_order`).
	 * @param WC_Email    $email        The WC_Email instance.
	 * @param bool        $success      Whether the email was sent successfully.
	 * @param string|null $error_reason The error message from wp_mail_failed, or null.
	 * @return void
	 */
	private function maybe_add_order_note( $wc_object, string $email_id, WC_Email $email, bool $success, ?string $error_reason ): void {
		if ( ! $wc_object instanceof WC_Order || ! $wc_object->get_object_read() ) {
			return;
		}

		/**
		 * Filter whether to add an order note for this transactional email attempt.
		 *
		 * Return false to suppress the order note for a particular email or globally,
		 * while still allowing the WooCommerce logger entry to be written.
		 *
		 * @since 10.9.0
		 *
		 * @param bool     $enabled  Whether to add the order note.
		 * @param string   $email_id The email type ID.
		 * @param WC_Email $email    The WC_Email instance.
		 * @param WC_Order $order    The order the note would be added to.
		 */
		if ( ! apply_filters( 'woocommerce_email_log_add_order_note', true, $email_id, $email, $wc_object ) ) {
			return;
		}

		$email_title = $email->get_title();
		$email_label = '' !== $email_title ? $email_title : $email_id;

		if ( $success ) {
			$note = sprintf(
				/* translators: %s: Email title or type identifier */
				__( 'Email "%s" sent.', 'woocommerce' ),
				$email_label
			);
		} elseif ( $error_reason ) {
			$note = sprintf(
				/* translators: 1: Email title or type identifier, 2: Error reason */
				__( 'Email "%1$s" failed to send: %2$s.', 'woocommerce' ),
				$email_label,
				$this->redact_emails( $error_reason )
			);
		} else {
			$note = sprintf(
				/* translators: %s: Email title or type identifier */
				__( 'Email "%s" failed to send.', 'woocommerce' ),
				$email_label
			);
		}

		$wc_object->add_order_note( $note, 0, false, array( 'note_group' => OrderNoteGroup::EMAIL_NOTIFICATION ) );
	}

	/**
	 * Handle the woocommerce_email_disabled action.
	 *
	 * @param string   $email_id The email type ID (e.g. `customer_processing_order`).
	 * @param WC_Email $email    The WC_Email instance.
	 * @return void
	 */
	public function handle_woocommerce_email_disabled( string $email_id, WC_Email $email ): void {
		$this->log_non_send_outcome( $email_id, $email, 'disabled' );
	}

	/**
	 * Handle the woocommerce_email_skipped action.
	 *
	 * @param string   $reason   Short identifier for why the email was skipped (e.g. 'no_recipient').
	 * @param string   $email_id The email type ID (e.g. `new_order`).
	 * @param WC_Email $email    The WC_Email instance.
	 * @return void
	 */
	public function handle_woocommerce_email_skipped( string $reason, string $email_id, WC_Email $email ): void {
		$this->log_non_send_outcome( $email_id, $email, '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 [email protected]"). 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,
		);
	}
}