', $from_email ); if ( ! is_array( $headers ) ) { $headers = array(); } // Merge the From header with caller-supplied headers. // Place the From header first. $final_headers = array_merge( array( $from_header ), $headers ); return $final_headers; } /** * Sends an email using wp_mail with error tracking. * * Captures wp_mail_failed errors during sending. Adds a default * Content-Type: text/html; charset=UTF-8 header when no caller header is * supplied, so wp_mail replacements that bypass PHPMailer still deliver * HTML. When text_content is provided, it's set as the AltBody for * multipart/alternative MIME emails. * * @since 1.168.0 * @since 1.170.0 Added $text_content parameter for plain text alternative. * @since 1.180.0 Inject default text/html Content-Type when none is supplied. * * @param string|array $to Array or comma-separated list of email addresses to send message. * @param string $subject Email subject. * @param string $content Message contents (HTML). * @param array $headers Optional. Additional headers. A default text/html Content-Type is added when none is supplied. Default empty array. * @param string $text_content Optional. Plain text alternative content. Default empty string. * @return bool|WP_Error True if the email was sent successfully, WP_Error on failure. */ public function send( $to, $subject, $content, $headers = array(), $text_content = '' ) { $this->last_error = null; $result = $this->send_email_and_catch_errors( $to, $subject, $content, $headers, $text_content ); if ( null !== $this->last_error ) { return $this->last_error; } if ( false === $result ) { $this->set_last_error(); return $this->last_error; } return true; } /** * Sends an email via wp_mail while capturing any errors. * * Attaches a temporary listener to wp_mail_failed to capture errors during * sending. Injects a default Content-Type: text/html header when no caller * Content-Type is present, so wp_mail replacements that respect the headers * argument deliver HTML. When text_content is set, uses phpmailer_init to * attach it as AltBody. PHPMailer then upgrades the content type to * multipart/alternative. * * @since 1.168.0 * @since 1.170.0 Added $text_content parameter for plain text alternative. * @since 1.180.0 Inject default text/html Content-Type when none is supplied. * * @param string|array $to Array or comma-separated list of email addresses to send message. * @param string $subject Email subject. * @param string $content Message contents (HTML). * @param array $headers Additional headers. * @param string $text_content Optional. Plain text alternative content. Default empty string. * @return bool Whether the email was sent successfully. */ protected function send_email_and_catch_errors( $to, $subject, $content, $headers = array(), $text_content = '' ) { add_action( 'wp_mail_failed', array( $this, 'set_last_error' ) ); if ( ! is_array( $headers ) ) { $headers = array(); } // Default to text/html so wp_mail replacements that don't fire // phpmailer_init still deliver as HTML. if ( ! $this->headers_contain_content_type( $headers ) ) { $headers[] = 'Content-Type: text/html; charset=UTF-8'; } // Set up AltBody for multipart MIME email if text content is provided. $alt_body_callback = null; if ( ! empty( $text_content ) ) { $alt_body_callback = function ( $phpmailer ) use ( $text_content ) { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase -- PHPMailer property. $phpmailer->AltBody = $text_content; }; add_action( 'phpmailer_init', $alt_body_callback ); } $result = wp_mail( $to, $subject, $content, $headers ); // phpcs:ignore WordPressVIPMinimum.Functions.RestrictedFunctions.wp_mail_wp_mail // Clean up hooks. remove_action( 'wp_mail_failed', array( $this, 'set_last_error' ) ); if ( null !== $alt_body_callback ) { remove_action( 'phpmailer_init', $alt_body_callback ); } return $result; } /** * Checks whether the headers array already contains a Content-Type header. * * Detection is case-insensitive and skips non-string entries. * * @since 1.180.0 * * @param array $headers Headers array passed to wp_mail. * @return bool True if a Content-Type header is present, false otherwise. */ private function headers_contain_content_type( array $headers ): bool { foreach ( $headers as $header ) { if ( ! is_string( $header ) ) { continue; } if ( 0 === stripos( ltrim( $header ), 'Content-Type:' ) ) { return true; } } return false; } /** * Sets the last error from a failed email attempt. * * This method is public because it is used as a callback for the * wp_mail_failed hook which requires public accessibility. * * @since 1.168.0 * * @param WP_Error|object|null $error The error from wp_mail_failed hook. We don't assume this to be * WP_Error. Some plugins that implement `wp_mail()` might not * always pass a `WP_Error` when doing the `wp_mail_failed` action. */ public function set_last_error( $error = null ) { if ( $error instanceof WP_Error ) { $this->last_error = $error; return; } $this->last_error = new WP_Error( 'wp_mail_failed', __( 'Failed to send email.', 'google-site-kit' ) ); } /** * Gets the last error from the most recent send attempt. * * @since 1.168.0 * * @return WP_Error|null The last error if one occurred, null otherwise. */ public function get_last_error() { return $this->last_error; } }