what it is, and * the core function that sends it. */ const SENDERS = array( 'password_reset' => array( 'Password reset link', 'retrieve_password()' ), 'new_user' => array( 'New account: login details, to the user', 'wp_new_user_notification()' ), 'new_user_admin' => array( 'New account: notice to the site admin', 'wp_new_user_notification()' ), 'password_changed' => array( 'Password changed, to the user', 'wp_update_user()' ), 'password_changed_admin' => array( 'Password changed, to the site admin', 'wp_password_change_notification()' ), 'email_changed' => array( 'Account email changed, to the old address', 'wp_update_user()' ), 'email_change_request' => array( 'Confirm a new account email', 'send_confirmation_on_profile_email()' ), 'admin_email_change_request' => array( 'Confirm a new site admin email', 'update_option_new_admin_email()' ), 'admin_email_changed' => array( 'Site admin email changed, to the old address', 'wp_site_admin_email_change_notification()' ), 'comment_notification' => array( 'New comment, to the post author', 'wp_notify_postauthor()' ), 'comment_moderation' => array( 'Comment awaiting moderation', 'wp_notify_moderator()' ), 'core_update' => array( 'WordPress updated automatically', 'WP_Automatic_Updater::send_email()' ), 'plugin_theme_update' => array( 'Plugins or themes updated automatically', 'WP_Automatic_Updater::send_plugin_theme_email()' ), 'update_debug' => array( 'Automatic update debug report', 'WP_Automatic_Updater::send_debug_email()' ), 'recovery_mode' => array( 'Technical issue (recovery mode) notice', 'WP_Recovery_Mode_Email_Service::maybe_send_recovery_mode_email()' ), 'privacy_request' => array( 'Personal data request: confirm', 'wp_send_user_request()' ), 'privacy_request_confirmed' => array( 'Personal data request confirmed, to the site admin', '_wp_privacy_send_request_confirmation_notification()' ), 'privacy_export_ready' => array( 'Personal data export ready', 'wp_privacy_send_personal_data_export_email()' ), 'privacy_erasure_done' => array( 'Personal data erased', '_wp_privacy_send_erasure_fulfillment_notification()' ), 'signup_site' => array( 'Multisite: activate a new site', 'wpmu_signup_blog_notification()' ), 'signup_user' => array( 'Multisite: activate a new account', 'wpmu_signup_user_notification()' ), 'welcome_site' => array( 'Multisite: new site welcome', 'wpmu_welcome_notification()' ), 'welcome_user' => array( 'Multisite: new account welcome', 'wpmu_welcome_user_notification()' ), 'network_admin_email_change_request' => array( 'Multisite: confirm a new network admin email', 'update_network_option_new_admin_email()' ), 'network_admin_email_changed' => array( 'Multisite: network admin email changed', 'wp_network_admin_email_change_notification()' ), ); /** The emails only a multisite network can send: their functions do not exist elsewhere. */ const MULTISITE = array( 'signup_site', 'signup_user', 'welcome_site', 'welcome_user', 'network_admin_email_change_request', 'network_admin_email_changed' ); /** Stands for "this option did not exist" when putting options back. */ const ABSENT = "\0adminkeep-absent"; /** Said of an email core decided not to send. */ const NOTHING_SENT = 'WordPress sent nothing. A filter on this site may switch this email off.'; /** * Where every email goes. * * @var string */ private $to = ''; /** * Every email wp_mail() was asked to send during the run, in order. * * @var array */ private $emails = array(); /** * The throwaway user, once made. * * @var \WP_User|null */ private $user = null; /** * The private post comments are left on, once made. * * @var int */ private $post_id = 0; /** * Personal data requests made, by action. * * @var array */ private $requests = array(); /** * Comments made. * * @var int[] */ private $comment_ids = array(); /** * Options as they were before the run changed them: name => value, or ABSENT. * * @var array */ private $options = array(); /** * Network options, the same way. * * @var array */ private $site_options = array(); /** * What the last run could not clean up. * * @var string[] */ private $left_behind = array(); /** * Filters added for the run: hook, callback, priority. * * @var array */ private $filters = array(); /** * The emails this site can send: id => array( what it is, the core function that sends it ). * * @return array */ public static function available() { $senders = self::SENDERS; if ( ! is_multisite() ) { $senders = array_diff_key( $senders, array_flip( self::MULTISITE ) ); } return $senders; } /** * Send each email to one address. * * @param string[] $ids Email ids, each one of available(). * @param string $to Recipient. * @return array * One row per email sent, or per email that could not be, in order. */ public function send( array $ids, $to ) { $this->to = $to; $this->emails = array(); $this->left_behind = array(); $rows = array(); $current_user = get_current_user_id(); // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Saved to be put back, never read. $post = $_POST; $had_errors = array_key_exists( 'errors', $GLOBALS ); $errors = $had_errors ? $GLOBALS['errors'] : null; $this->add_filter( 'wp_mail', array( $this, 'redirect' ), self::REDIRECT_PRIORITY ); $this->add_filter( 'wp_mail_succeeded', array( $this, 'succeeded' ), 10 ); $this->add_filter( 'wp_mail_failed', array( $this, 'failed' ), 10 ); try { foreach ( $ids as $id ) { $rows = array_merge( $rows, $this->send_one( $id ) ); } } finally { $this->clean_up(); wp_set_current_user( $current_user ); $_POST = $post; if ( $had_errors ) { $GLOBALS['errors'] = $errors; // phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited -- Putting back what send_confirmation_on_profile_email() replaced. } else { unset( $GLOBALS['errors'] ); } } return $rows; } /** * Send one email and report on what wp_mail() did with it. * * @param string $id Email id. * @return array[] Rows. */ private function send_one( $id ) { $first = count( $this->emails ); $error = ''; try { $this->{ 'send_' . $id }(); } catch ( \Throwable $e ) { $error = $e->getMessage(); } $emails = array_slice( $this->emails, $first ); if ( empty( $emails ) ) { return array( array( 'id' => $id, 'subject' => '', 'wrapped' => 'no', 'status' => 'failed', 'error' => '' !== $error ? $error : self::NOTHING_SENT, ), ); } $rows = array(); foreach ( $emails as $email ) { $rows[] = array( 'id' => $id, 'subject' => $email['subject'], 'wrapped' => $email['wrapped'] ? 'yes' : 'no', 'status' => 'sent' === $email['status'] ? 'sent' : 'failed', 'error' => 'sent' === $email['status'] ? '' : ( '' !== $email['error'] ? $email['error'] : 'wp_mail() stopped before sending.' ), ); } return $rows; } /** * The wp_mail filter: send to the one address, and note the email. * * @param array $atts wp_mail() arguments. * @return array */ public function redirect( $atts ) { if ( ! is_array( $atts ) ) { return $atts; } $atts['to'] = $this->to; if ( isset( $atts['headers'] ) ) { $atts['headers'] = self::without_copies( $atts['headers'] ); } $message = isset( $atts['message'] ) && is_string( $atts['message'] ) ? $atts['message'] : ''; $this->emails[] = array( 'subject' => isset( $atts['subject'] ) ? (string) $atts['subject'] : '', // Nice Default Emails has run by now; its layout is the only HTML a core email carries. 'wrapped' => 0 === strpos( $message, '' ), 'status' => 'pending', 'error' => '', ); return $atts; } /** * The last email left the site. * * @return void */ public function succeeded() { $this->settle( 'sent', '' ); } /** * The last email did not. * * @param \WP_Error $error Why. * @return void */ public function failed( $error ) { $this->settle( 'failed', is_wp_error( $error ) ? $error->get_error_message() : '' ); } /** * Record how the last email went. * * @param string $status sent or failed. * @param string $error Why it failed. * @return void */ private function settle( $status, $error ) { $last = count( $this->emails ) - 1; if ( $last >= 0 && 'pending' === $this->emails[ $last ]['status'] ) { $this->emails[ $last ]['status'] = $status; $this->emails[ $last ]['error'] = (string) $error; } } /** * Headers without Cc or Bcc, in the shape they came in. * * @param string|array $headers wp_mail() headers. * @return string|array */ private static function without_copies( $headers ) { $lines = is_array( $headers ) ? $headers : explode( "\n", str_replace( "\r\n", "\n", (string) $headers ) ); $kept = array(); foreach ( $lines as $line ) { if ( is_string( $line ) && preg_match( '/^\s*b?cc\s*:/i', $line ) ) { continue; } $kept[] = $line; } return is_array( $headers ) ? $kept : implode( "\n", $kept ); } // ------------------------------------------------------------------------------------------- // Accounts. // ------------------------------------------------------------------------------------------- /** * Password reset. * * @return void */ private function send_password_reset() { $this->check( retrieve_password( $this->user()->user_login ) ); } /** * New account, to the user: a link to set a password. * * @return void */ private function send_new_user() { wp_new_user_notification( $this->user()->ID, null, 'user' ); } /** * New account, to the site admin. * * @return void */ private function send_new_user_admin() { wp_new_user_notification( $this->user()->ID, null, 'admin' ); } /** * Password changed, to the user. Core sends it when wp_update_user() changes a password. * * @return void */ private function send_password_changed() { $this->check( wp_update_user( array( 'ID' => $this->user()->ID, 'user_pass' => wp_generate_password( 32 ), ) ) ); } /** * Password changed, to the site admin. * * @return void */ private function send_password_changed_admin() { wp_password_change_notification( $this->user() ); } /** * Account email changed, to the old address. Core sends it when wp_update_user() changes one. * * @return void */ private function send_email_changed() { $user = $this->user(); $this->check( wp_update_user( array( 'ID' => $user->ID, 'user_email' => $this->address( 'changed' ), ) ) ); $this->user = get_userdata( $user->ID ); } /** * "Confirm your new email", as the profile screen sends it: for the signed-in user, reading the * new address from the posted form. The run signs in as the throwaway user for the call. * * @return void */ private function send_email_change_request() { $user = $this->user(); $current_user = get_current_user_id(); $post = $_POST; // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Saved to be put back, never read. wp_set_current_user( $user->ID ); $_POST['email'] = $this->address( 'new' ); try { send_confirmation_on_profile_email( $user->ID ); } finally { // Signed in as the post author, the comment emails that follow would be skipped as the // author's own doing. wp_set_current_user( $current_user ); $_POST = $post; } } /** * "Confirm the new admin email", as Settings > General sends it. The pending change core stores * is put back afterwards, so the link in the email changes nothing. * * @return void */ private function send_admin_email_change_request() { require_once ABSPATH . 'wp-admin/includes/misc.php'; $this->keep_option( 'adminhash' ); update_option_new_admin_email( get_option( 'admin_email' ), $this->address( 'admin' ) ); } /** * Site admin email changed, to the old address. * * @return void */ private function send_admin_email_changed() { wp_site_admin_email_change_notification( get_option( 'admin_email' ), $this->address( 'admin' ), 'admin_email' ); } // ------------------------------------------------------------------------------------------- // Comments. // ------------------------------------------------------------------------------------------- /** * New comment, to the post author. * * @return void */ private function send_comment_notification() { wp_notify_postauthor( $this->comment( '1' ) ); } /** * Comment awaiting moderation, to the site admin. Sites can switch this email off in Settings > * Discussion; core's own notify_moderator filter overrides that for the one call. * * @return void */ private function send_comment_moderation() { $comment_id = $this->comment( '0' ); $this->add_filter( 'notify_moderator', '__return_true', 10 ); try { wp_notify_moderator( $comment_id ); } finally { $this->remove_filter( 'notify_moderator', '__return_true', 10 ); } } // ------------------------------------------------------------------------------------------- // Updates and site health. // ------------------------------------------------------------------------------------------- /** * "Your site has updated to WordPress x.y", for the version the site runs. * * @return void */ private function send_core_update() { $version = get_bloginfo( 'version' ); $this->keep_site_option( 'auto_core_update_notified' ); $this->updater()->core_email( 'success', (object) array( 'response' => 'latest', 'current' => $version, 'version' => $version, 'locale' => get_locale(), ) ); } /** * "Some plugins were automatically updated", for one example plugin. * * @return void */ private function send_plugin_theme_update() { $this->keep_option( 'auto_plugin_theme_update_emails' ); $this->updater()->plugin_theme_email( 'success', array( 'plugin' => array( $this->example_update() ) ), array() ); } /** * The debug report core sends after an update run on a development version. * * @return void */ private function send_update_debug() { $this->updater()->debug_email( array( 'plugin' => array( $this->example_update() ) ) ); } /** * "Your site is experiencing a technical issue", naming a plugin that does not exist and an * error that says it is a test. Core stores a recovery mode key for the link in the email; the * key and the rate limit are put back afterwards, so the link does not work. * * @return void */ private function send_recovery_mode() { $this->keep_option( 'recovery_keys' ); $this->keep_option( 'recovery_mode_email_last_sent' ); // The rate limit would otherwise hold the email back if one went out in the last day. delete_option( 'recovery_mode_email_last_sent' ); $service = new \WP_Recovery_Mode_Email_Service( new \WP_Recovery_Mode_Link_Service( new \WP_Recovery_Mode_Cookie_Service(), new \WP_Recovery_Mode_Key_Service() ) ); $this->check( $service->maybe_send_recovery_mode_email( HOUR_IN_SECONDS, array( 'type' => E_ERROR, 'message' => 'This is a test email from wp adminkeep emails send. Nothing is wrong with your site.', 'file' => WP_PLUGIN_DIR . '/example-plugin/example-plugin.php', 'line' => 1, ), array( 'type' => 'plugin', 'slug' => 'example-plugin', ) ) ); } // ------------------------------------------------------------------------------------------- // Personal data requests. // ------------------------------------------------------------------------------------------- /** * "Confirm your personal data export request". * * @return void */ private function send_privacy_request() { $this->check( wp_send_user_request( $this->request( 'export_personal_data' ) ) ); } /** * Request confirmed, to the site admin: core marks the request confirmed, then sends. * * @return void */ private function send_privacy_request_confirmed() { $request_id = $this->request( 'export_personal_data' ); _wp_privacy_account_request_confirmed( $request_id ); _wp_privacy_send_request_confirmation_notification( $request_id ); } /** * "Your personal data export is ready", linking to an export file that does not exist. * * @return void */ private function send_privacy_export_ready() { require_once ABSPATH . 'wp-admin/includes/privacy-tools.php'; $request_id = $this->request( 'export_personal_data' ); update_post_meta( $request_id, '_export_file_name', 'wp-personal-data-file-example.zip' ); $this->check( wp_privacy_send_personal_data_export_email( $request_id ) ); } /** * "Your personal data was erased": core marks an erasure request completed, then sends. * * @return void */ private function send_privacy_erasure_done() { require_once ABSPATH . 'wp-admin/includes/privacy-tools.php'; $request_id = $this->request( 'remove_personal_data' ); $this->check( _wp_privacy_completed_request( $request_id ) ); _wp_privacy_send_erasure_fulfillment_notification( $request_id ); } // ------------------------------------------------------------------------------------------- // Multisite. Each function sends and stores nothing, except where noted. // ------------------------------------------------------------------------------------------- /** * "Activate your new site". The key matches no sign-up, so the link activates nothing. * * @return void */ private function send_signup_site() { $network = get_network(); $login = $this->user()->user_login; wpmu_signup_blog_notification( $network->domain, $network->path . 'example-site/', 'Example Site', $login, $this->address( 'signup' ), wp_generate_password( 16, false ) ); } /** * "Activate your new account". The key matches no sign-up. * * @return void */ private function send_signup_user() { wpmu_signup_user_notification( $this->user()->user_login, $this->address( 'signup' ), wp_generate_password( 16, false ) ); } /** * The welcome email for a new site, sent for this one. The password in it is a placeholder. * * @return void */ private function send_welcome_site() { wpmu_welcome_notification( get_current_blog_id(), $this->user()->ID, 'example-password', get_bloginfo( 'name' ) ); } /** * The welcome email for a new account. The password in it is a placeholder. * * @return void */ private function send_welcome_user() { wpmu_welcome_user_notification( $this->user()->ID, 'example-password' ); } /** * "Confirm the new network admin email". The pending change is put back afterwards. * * @return void */ private function send_network_admin_email_change_request() { $this->keep_site_option( 'network_admin_hash' ); update_network_option_new_admin_email( get_site_option( 'admin_email' ), $this->address( 'admin' ) ); } /** * Network admin email changed, to the old address. * * @return void */ private function send_network_admin_email_changed() { wp_network_admin_email_change_notification( 'admin_email', $this->address( 'admin' ), get_site_option( 'admin_email' ), get_current_network_id() ); } // ------------------------------------------------------------------------------------------- // What the emails need, and putting it all back. // ------------------------------------------------------------------------------------------- /** * The throwaway subscriber the account emails are about, made on first use. * * @return \WP_User */ private function user() { if ( null === $this->user ) { $login = 'adminkeep-sample-' . strtolower( wp_generate_password( 8, false ) ); $id = wp_insert_user( array( 'user_login' => $login, 'user_email' => $login . '@example.com', 'user_pass' => wp_generate_password( 32 ), 'display_name' => 'Sample User', 'role' => 'subscriber', ) ); $this->check( $id ); $this->user = get_userdata( $id ); } return $this->user; } /** * An address at example.com for the emails that are about a second address. It never receives * anything: every email goes to the one address given. * * @param string $purpose Word to tell the addresses apart. * @return string */ private function address( $purpose ) { return $this->user()->user_login . '-' . $purpose . '@example.com'; } /** * A comment on a private post by the throwaway user. Private, so publishing it schedules no * pings; the author can still read it, which is what the post author notification checks. * * @param string $approved '1' approved, '0' awaiting moderation. * @return int Comment id. */ private function comment( $approved ) { if ( 0 === $this->post_id ) { $post_id = wp_insert_post( array( 'post_author' => $this->user()->ID, 'post_title' => 'Example post', 'post_content' => 'A post made by wp adminkeep emails send, and deleted once it is done.', 'post_status' => 'private', 'post_type' => 'post', ), true ); $this->check( $post_id ); $this->post_id = (int) $post_id; } $comment_id = wp_insert_comment( array( 'comment_post_ID' => $this->post_id, 'comment_author' => 'Sample Commenter', 'comment_author_email' => 'commenter@example.com', 'comment_author_url' => 'https://example.com/', 'comment_author_IP' => '192.0.2.1', 'comment_content' => "Thanks for this post. It answered the question I had.\n\nOne more: does it work on multisite too?", 'comment_approved' => $approved, 'comment_type' => 'comment', ) ); if ( ! $comment_id ) { throw new \RuntimeException( 'Could not add the example comment.' ); } $this->comment_ids[] = (int) $comment_id; return (int) $comment_id; } /** * A personal data request from the throwaway user, made on first use. * * @param string $action export_personal_data or remove_personal_data. * @return int Request id. */ private function request( $action ) { if ( ! isset( $this->requests[ $action ] ) ) { $request_id = wp_create_user_request( $this->user()->user_email, $action ); $this->check( $request_id ); $this->requests[ $action ] = (int) $request_id; } return $this->requests[ $action ]; } /** * A plugin update as core records one, for a plugin that does not exist. * * @return object */ private function example_update() { return (object) array( 'name' => 'Example Plugin', 'result' => true, 'messages' => array( 'Updating Example Plugin…', 'Plugin updated successfully.' ), 'item' => (object) array( 'plugin' => 'example-plugin/example-plugin.php', 'slug' => 'example-plugin', 'current_version' => '1.0.0', 'new_version' => '1.1.0', 'url' => '', ), ); } /** * Core's automatic updater, with its email methods in reach. * * @return CoreEmailsUpdater */ private function updater() { require_once ABSPATH . 'wp-admin/includes/class-wp-automatic-updater.php'; return new CoreEmailsUpdater(); } /** * Remember an option before the run changes it. * * @param string $name Option name. * @return void */ private function keep_option( $name ) { if ( ! array_key_exists( $name, $this->options ) ) { $this->options[ $name ] = get_option( $name, self::ABSENT ); } } /** * Remember a network option before the run changes it. * * @param string $name Option name. * @return void */ private function keep_site_option( $name ) { if ( ! array_key_exists( $name, $this->site_options ) ) { $this->site_options[ $name ] = get_site_option( $name, self::ABSENT ); } } /** * Add a filter for the run. * * @param string $hook Hook. * @param callable $callback Callback. * @param int $priority Priority. * @return void */ private function add_filter( $hook, $callback, $priority ) { add_filter( $hook, $callback, $priority, 1 ); $this->filters[] = array( $hook, $callback, $priority ); } /** * Remove a filter the run added. * * @param string $hook Hook. * @param callable $callback Callback. * @param int $priority Priority. * @return void */ private function remove_filter( $hook, $callback, $priority ) { remove_filter( $hook, $callback, $priority ); foreach ( $this->filters as $index => $filter ) { if ( array( $hook, $callback, $priority ) === $filter ) { unset( $this->filters[ $index ] ); } } } /** * Stop on a WP_Error or a false result. * * @param mixed $result What a core function returned. * @return void * @throws \RuntimeException When it failed. */ private function check( $result ) { if ( is_wp_error( $result ) ) { // Caught in send_one() and printed on the command line, never on a page. Core's messages // can carry markup (Error:), which means nothing in a terminal. throw new \RuntimeException( wp_strip_all_tags( $result->get_error_message() ) ); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- Command-line output, see above. } if ( false === $result || 0 === $result ) { throw new \RuntimeException( 'WordPress refused to send this email.' ); } } /** * Delete everything the run made and put back every option it changed. Each step runs even if * one before it fails, so one stubborn row cannot keep the rest in place; what failed is kept * for the caller to report. * * @return void */ private function clean_up() { foreach ( $this->filters as $filter ) { remove_filter( $filter[0], $filter[1], $filter[2] ); } $steps = array(); foreach ( $this->comment_ids as $comment_id ) { $steps[ 'comment ' . $comment_id ] = static function () use ( $comment_id ) { return wp_delete_comment( $comment_id, true ); }; } foreach ( array_merge( array_values( $this->requests ), array( $this->post_id ) ) as $post_id ) { if ( $post_id ) { $steps[ 'post ' . $post_id ] = static function () use ( $post_id ) { return (bool) wp_delete_post( $post_id, true ); }; } } if ( null !== $this->user ) { $user_id = $this->user->ID; $steps[ 'user ' . $user_id ] = static function () use ( $user_id ) { require_once ABSPATH . 'wp-admin/includes/user.php'; if ( is_multisite() ) { require_once ABSPATH . 'wp-admin/includes/ms.php'; return wpmu_delete_user( $user_id ); } return wp_delete_user( $user_id ); }; } foreach ( $this->options as $name => $value ) { $steps[ 'option ' . $name ] = static function () use ( $name, $value ) { return self::ABSENT === $value ? ( delete_option( $name ) || false === get_option( $name ) ) : ( update_option( $name, $value ) || get_option( $name ) === $value ); }; } foreach ( $this->site_options as $name => $value ) { $steps[ 'network option ' . $name ] = static function () use ( $name, $value ) { return self::ABSENT === $value ? ( delete_site_option( $name ) || false === get_site_option( $name ) ) : ( update_site_option( $name, $value ) || get_site_option( $name ) === $value ); }; } foreach ( $steps as $what => $step ) { try { $done = $step(); } catch ( \Throwable $e ) { $done = false; } if ( ! $done ) { $this->left_behind[] = $what; } } $this->filters = array(); $this->comment_ids = array(); $this->requests = array(); $this->post_id = 0; $this->user = null; $this->options = array(); $this->site_options = array(); } /** * What the last run could not delete or put back, e.g. "user 12" or "option adminhash". * * @return string[] */ public function left_behind() { return $this->left_behind; } }