diff --git a/src/wp-admin/includes/class-wp-site-health.php b/src/wp-admin/includes/class-wp-site-health.php index 2ddfaadc4b39d..5e9d9c5a73e69 100644 --- a/src/wp-admin/includes/class-wp-site-health.php +++ b/src/wp-admin/includes/class-wp-site-health.php @@ -84,6 +84,98 @@ public static function get_instance() { return self::$instance; } + /** + * Sends a test email to the current user's email address. + * + * A successful result means that WordPress handed the message to the mail + * system without an immediate error. It does not confirm delivery. + * + * @since 7.2.0 + * + * @return true|WP_Error True on success, or a WP_Error on failure. + */ + public function send_email_delivery_test() { + $user = wp_get_current_user(); + $email = $user->user_email; + + if ( ! $user->exists() || ! is_email( $email ) ) { + return new WP_Error( 'invalid-address' ); + } + + $site_name = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + $subject = sprintf( + /* translators: %s: Site title. */ + __( '[%s] Email delivery test' ), + $site_name + ); + $message = sprintf( + /* translators: 1: Site title. 2: Site URL. */ + __( "This is a test email sent from your WordPress site, %1\$s.\n\nReceiving this message confirms that it reached this inbox. Review the message headers to confirm that the sender details are correct.\n\nSite URL: %2\$s" ), + $site_name, + home_url( '/' ) + ); + + if ( ! wp_mail( $email, $subject, $message ) ) { + return new WP_Error( 'send-failed' ); + } + + update_option( + 'email_delivery_last_tested', + array( + 'timestamp' => time(), + 'user_id' => $user->ID, + 'email' => $email, + ), + false + ); + + return true; + } + + /** + * Gets the timestamp of the most recent valid email delivery test. + * + * @since 7.2.0 + * + * @return int The test timestamp, or 0 if no valid timestamp exists. + */ + private function get_email_delivery_last_tested() { + $last_tested = get_option( 'email_delivery_last_tested' ); + + if ( + ! is_array( $last_tested ) || + ! isset( $last_tested['timestamp'], $last_tested['user_id'], $last_tested['email'] ) || + ! is_numeric( $last_tested['timestamp'] ) || + 0 >= (int) $last_tested['timestamp'] || + (int) $last_tested['timestamp'] > time() || + ! is_numeric( $last_tested['user_id'] ) || + 0 >= (int) $last_tested['user_id'] || + ! is_string( $last_tested['email'] ) || + ! is_email( $last_tested['email'] ) + ) { + return 0; + } + + $user = get_userdata( (int) $last_tested['user_id'] ); + + if ( ! $user || 0 !== strcasecmp( $user->user_email, $last_tested['email'] ) ) { + return 0; + } + + return (int) $last_tested['timestamp']; + } + + /** + * Determines whether the most recent email delivery test is current. + * + * @since 7.2.0 + * + * @return bool Whether email delivery was tested within the required period. + */ + private function is_email_delivery_test_current() { + return $this->get_email_delivery_last_tested() > time() - ( 3 * MONTH_IN_SECONDS ); + } + /** * Enqueues the site health scripts. * @@ -2791,6 +2883,49 @@ public function get_test_search_engine_visibility() { return $result; } + /** + * Tests whether email delivery has been tested recently. + * + * @since 7.2.0 + * + * @return array The test results. + */ + public function get_test_email_delivery() { + $was_tested_recently = $this->is_email_delivery_test_current(); + $action = sprintf( + '
%2$s
', + esc_url( admin_url( 'site-health.php' ) ), + wp_nonce_field( 'verify-email-delivery', '_wpnonce', true, false ), + esc_html__( 'Verify email delivery' ) + ); + + $result = array( + 'label' => __( 'Email delivery was recently tested' ), + 'status' => 'good', + 'badge' => array( + 'label' => __( 'Performance' ), + 'color' => 'blue', + ), + 'description' => sprintf( + '

%s

', + __( 'WordPress recently handed a test email to the mail system without an immediate error. This does not guarantee that the message reached the inbox.' ) + ), + 'actions' => $action, + 'test' => 'email_delivery', + ); + + if ( ! $was_tested_recently ) { + $result['label'] = __( 'Email delivery should be tested' ); + $result['status'] = 'recommended'; + $result['description'] = sprintf( + '

%s

', + __( 'WordPress relies on email for important site administration messages. Send a test email to your user account, then confirm that it reaches the inbox and that its sender headers are correct.' ) + ); + } + + return $result; + } + /** * Tests if opcode cache is enabled and available. * @@ -2896,6 +3031,10 @@ public static function get_tests() { 'label' => __( 'HTTP Requests' ), 'test' => 'http_requests', ), + 'email_delivery' => array( + 'label' => __( 'Email delivery' ), + 'test' => 'email_delivery', + ), 'rest_availability' => array( 'label' => __( 'REST API availability' ), 'test' => 'rest_availability', diff --git a/src/wp-admin/site-health.php b/src/wp-admin/site-health.php index 1a70e2e1dd8f1..da8d155377d0a 100644 --- a/src/wp-admin/site-health.php +++ b/src/wp-admin/site-health.php @@ -74,6 +74,26 @@ $health_check_site_status = WP_Site_Health::get_instance(); +if ( 'verify_email_delivery' === $action ) { + check_admin_referer( 'verify-email-delivery' ); + + $result = $health_check_site_status->send_email_delivery_test(); + $status = 'success'; + + if ( is_wp_error( $result ) ) { + $status = $result->get_error_code(); + } + + wp_safe_redirect( + add_query_arg( + 'email-delivery-test', + $status, + admin_url( 'site-health.php' ) + ) + ); + exit; +} + get_current_screen()->add_help_tab( array( 'id' => 'overview', @@ -124,6 +144,43 @@ ); } } + + $email_delivery_test_status = ''; + // The query parameter only selects a notice to display. + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( isset( $_GET['email-delivery-test'] ) && is_string( $_GET['email-delivery-test'] ) ) { + $email_delivery_test_status = sanitize_key( wp_unslash( $_GET['email-delivery-test'] ) ); + } + + if ( 'success' === $email_delivery_test_status ) { + wp_admin_notice( + sprintf( + /* translators: %s: Current user's email address. */ + __( 'WordPress accepted a request to send a test email to %s. Check that it arrived in your inbox and review its headers to confirm that the sender details are correct.' ), + '' . esc_html( wp_get_current_user()->user_email ) . '' + ), + array( + 'type' => 'success', + 'dismissible' => true, + ) + ); + } elseif ( 'invalid-address' === $email_delivery_test_status ) { + wp_admin_notice( + __( 'Your user account does not have a valid email address. Update it before testing email delivery.' ), + array( + 'type' => 'error', + 'dismissible' => true, + ) + ); + } elseif ( 'send-failed' === $email_delivery_test_status ) { + wp_admin_notice( + __( 'The test email could not be sent. Your site may not be correctly configured to send emails.' ), + array( + 'type' => 'error', + 'dismissible' => true, + ) + ); + } ?>
diff --git a/tests/phpunit/tests/admin/wpSiteHealth.php b/tests/phpunit/tests/admin/wpSiteHealth.php index 6080b477f54c3..89df631418695 100644 --- a/tests/phpunit/tests/admin/wpSiteHealth.php +++ b/tests/phpunit/tests/admin/wpSiteHealth.php @@ -31,6 +31,34 @@ public function set_up() { $this->instance = new WP_Site_Health(); } + /** + * Performs cleanup tasks after every test. + */ + public function tear_down() { + delete_option( 'email_delivery_last_tested' ); + wp_set_current_user( 0 ); + + parent::tear_down(); + } + + /** + * Creates and sets the current user for an email delivery test. + * + * @param string $email User email address. + * @return WP_User The current user. + */ + private function set_email_delivery_test_user( $email = 'site-health@example.org' ) { + $user = self::factory()->user->create_and_get( + array( + 'role' => 'administrator', + 'user_email' => $email, + ) + ); + wp_set_current_user( $user->ID ); + + return $user; + } + /** * @ticket 55791 * @covers ::__construct() @@ -649,6 +677,243 @@ public static function set_autoloaded_option( $bytes = 800000 ) { add_option( 'test_set_autoloaded_option', $heavy_option_string, '', true ); } + /** + * Tests the email delivery Site Health status. + * + * @ticket 65891 + * @dataProvider data_email_delivery_status + * @covers ::get_email_delivery_last_tested() + * @covers ::get_test_email_delivery() + * + * @param string $state The stored timestamp state. + * @param string $expected_status The expected Site Health status. + */ + public function test_email_delivery_status( $state, $expected_status ) { + $user = $this->set_email_delivery_test_user(); + $test_data = array( + 'timestamp' => time() - DAY_IN_SECONDS, + 'user_id' => $user->ID, + 'email' => $user->user_email, + ); + + switch ( $state ) { + case 'malformed': + update_option( 'email_delivery_last_tested', 'not-a-timestamp', false ); + break; + case 'future': + $test_data['timestamp'] = time() + HOUR_IN_SECONDS; + update_option( 'email_delivery_last_tested', $test_data, false ); + break; + case 'expired': + $test_data['timestamp'] = time() - ( 3 * MONTH_IN_SECONDS ); + update_option( 'email_delivery_last_tested', $test_data, false ); + break; + case 'recent': + update_option( 'email_delivery_last_tested', $test_data, false ); + break; + } + + $result = $this->instance->get_test_email_delivery(); + + $this->assertSame( $expected_status, $result['status'] ); + $this->assertSame( 'email_delivery', $result['test'] ); + $this->assertSame( + array( + 'label' => __( 'Performance' ), + 'color' => 'blue', + ), + $result['badge'] + ); + + $this->assertStringContainsString( 'site-health.php', $result['actions'] ); + $this->assertStringNotContainsString( 'options-general.php', $result['actions'] ); + } + + /** + * Data provider for test_email_delivery_status(). + * + * @return array[] Test parameters. + */ + public function data_email_delivery_status() { + return array( + 'missing timestamp' => array( 'missing', 'recommended' ), + 'malformed timestamp' => array( 'malformed', 'recommended' ), + 'future timestamp' => array( 'future', 'recommended' ), + 'expired timestamp' => array( 'expired', 'recommended' ), + 'recent timestamp' => array( 'recent', 'good' ), + ); + } + + /** + * Tests a successful email delivery test. + * + * @ticket 65891 + * @covers ::send_email_delivery_test() + */ + public function test_send_email_delivery_test_success() { + global $wpdb; + + $user = $this->set_email_delivery_test_user(); + $mail_data = null; + $callback = static function ( $short_circuit, $atts ) use ( &$mail_data ) { + $mail_data = $atts; + return true; + }; + add_filter( 'pre_wp_mail', $callback, 10, 2 ); + + $result = $this->instance->send_email_delivery_test(); + + remove_filter( 'pre_wp_mail', $callback ); + + $this->assertTrue( $result ); + $this->assertSame( $user->user_email, $mail_data['to'] ); + $this->assertStringContainsString( get_option( 'blogname' ), $mail_data['subject'] ); + $this->assertStringContainsString( home_url( '/' ), $mail_data['message'] ); + + $test_data = get_option( 'email_delivery_last_tested' ); + $this->assertGreaterThan( 0, $test_data['timestamp'] ); + $this->assertSame( $user->ID, $test_data['user_id'] ); + $this->assertSame( $user->user_email, $test_data['email'] ); + $this->assertSame( 'good', $this->instance->get_test_email_delivery()['status'] ); + $this->assertSame( + 'off', + $wpdb->get_var( + $wpdb->prepare( + "SELECT autoload FROM $wpdb->options WHERE option_name = %s", + 'email_delivery_last_tested' + ) + ) + ); + } + + /** + * Tests an immediate email sending failure. + * + * @ticket 65891 + * @covers ::send_email_delivery_test() + */ + public function test_send_email_delivery_test_failure() { + $this->set_email_delivery_test_user(); + add_filter( 'pre_wp_mail', '__return_false' ); + + $result = $this->instance->send_email_delivery_test(); + + remove_filter( 'pre_wp_mail', '__return_false' ); + + $this->assertWPError( $result ); + $this->assertSame( 'send-failed', $result->get_error_code() ); + $this->assertFalse( get_option( 'email_delivery_last_tested', false ) ); + } + + /** + * Tests a current user without a valid email address. + * + * @ticket 65891 + * @covers ::send_email_delivery_test() + */ + public function test_send_email_delivery_test_invalid_address() { + $result = $this->instance->send_email_delivery_test(); + + $this->assertWPError( $result ); + $this->assertSame( 'invalid-address', $result->get_error_code() ); + $this->assertFalse( get_option( 'email_delivery_last_tested', false ) ); + } + + /** + * Tests that changing the tested user's email invalidates the result. + * + * @ticket 65891 + * @covers ::get_email_delivery_last_tested() + * @covers ::get_test_email_delivery() + */ + public function test_email_delivery_status_is_invalid_after_user_email_changes() { + $user = $this->set_email_delivery_test_user( 'before-change@example.org' ); + update_option( + 'email_delivery_last_tested', + array( + 'timestamp' => time() - DAY_IN_SECONDS, + 'user_id' => $user->ID, + 'email' => $user->user_email, + ), + false + ); + + wp_update_user( + array( + 'ID' => $user->ID, + 'user_email' => 'after-change@example.org', + ) + ); + + $this->assertSame( 'recommended', $this->instance->get_test_email_delivery()['status'] ); + } + + /** + * Tests that deleting the tested user invalidates the result. + * + * @ticket 65891 + * @covers ::get_email_delivery_last_tested() + * @covers ::get_test_email_delivery() + */ + public function test_email_delivery_status_is_invalid_after_user_is_deleted() { + $user = $this->set_email_delivery_test_user(); + update_option( + 'email_delivery_last_tested', + array( + 'timestamp' => time() - DAY_IN_SECONDS, + 'user_id' => $user->ID, + 'email' => $user->user_email, + ), + false + ); + + if ( is_multisite() ) { + wpmu_delete_user( $user->ID ); + } else { + wp_delete_user( $user->ID ); + } + wp_set_current_user( 0 ); + + $this->assertSame( 'recommended', $this->instance->get_test_email_delivery()['status'] ); + } + + /** + * Tests that a recent email delivery result applies site-wide. + * + * @ticket 65891 + * @covers ::get_email_delivery_last_tested() + * @covers ::get_test_email_delivery() + */ + public function test_email_delivery_status_applies_to_other_administrators() { + $user = $this->set_email_delivery_test_user(); + update_option( + 'email_delivery_last_tested', + array( + 'timestamp' => time() - DAY_IN_SECONDS, + 'user_id' => $user->ID, + 'email' => $user->user_email, + ), + false + ); + + $this->set_email_delivery_test_user( 'another-admin@example.org' ); + + $this->assertSame( 'good', $this->instance->get_test_email_delivery()['status'] ); + } + + /** + * Tests that the email delivery check is registered as a direct test. + * + * @ticket 65891 + * @covers ::get_tests() + */ + public function test_email_delivery_test_is_registered() { + $tests = WP_Site_Health::get_tests(); + + $this->assertArrayHasKey( 'email_delivery', $tests['direct'] ); + $this->assertSame( 'email_delivery', $tests['direct']['email_delivery']['test'] ); + } + /** * Tests get_test_opcode_cache() return structure. *