From e73e7c911f7fe35f390fd5e8c25b4e985994e832 Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 11:23:03 +0200 Subject: [PATCH 1/2] feat(email): add periodic health check notification Schedule a daily WP-Cron event that checks all installed plugins for problems (closed, stale >2y, untested with current WP) and sends a summary email to the site admin. Deduplicates via hash comparison to avoid repeated emails for the same issue set. Clears the notification flag on plugin updates, activation/deactivation, and manual cache clear. --- rt-plugin-report.php | 188 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 188 insertions(+) diff --git a/rt-plugin-report.php b/rt-plugin-report.php index 6b76f78..2ee0b78 100644 --- a/rt-plugin-report.php +++ b/rt-plugin-report.php @@ -60,6 +60,11 @@ public function init() { add_action( 'wp_ajax_rt_get_plugin_info', array( $this, 'get_plugin_info' ) ); // Hook into the WP Upgrader to selectively delete cache items. add_action( 'upgrader_process_complete', array( $this, 'upgrade_delete_cache_items' ), 10, 2 ); + // Periodic health check for abandoned/problematic plugins. + add_action( 'plugin_report_health_check', array( $this, 'run_health_check' ) ); + // Reset notification flag when plugins change. + add_action( 'activated_plugin', array( $this, 'clear_notification_flag' ) ); + add_action( 'deactivated_plugin', array( $this, 'clear_notification_flag' ) ); } @@ -745,6 +750,8 @@ private function clear_cache() { $slug = $this->get_plugin_slug( $key ); $this->clear_cache_item( $slug ); } + // Reset notification flag so the next health check re-evaluates. + $this->clear_notification_flag(); } @@ -770,13 +777,194 @@ public function upgrade_delete_cache_items( $upgrader, $data ) { $slug = $this->get_plugin_slug( $value ); $this->clear_cache_item( $slug ); } + // Reset notification flag so the next health check re-evaluates. + $this->clear_notification_flag(); } } + /** + * Run the periodic health check for abandoned/problematic plugins. + * Sends an email to the site admin when issues are found. + */ + public function run_health_check() { + // Ensure plugins_api is available. + if ( ! function_exists( 'plugins_api' ) ) { + require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; + } + + $plugins = get_plugins(); + $wp_latest = $this->check_core_updates(); + $closed = array(); + $stale = array(); + $untested = array(); + + foreach ( $plugins as $key => $plugin ) { + $slug = $this->get_plugin_slug( $key ); + $report = $this->assemble_plugin_report( $slug ); + + if ( ! $report ) { + continue; + } + + $name = isset( $report['local_info']['Name'] ) ? $report['local_info']['Name'] : $slug; + + // Closed on wp.org. + if ( isset( $report['repo_error_code'] ) && 'plugins_api_failed' === $report['repo_error_code'] && isset( $report['exists_in_svn'] ) && true === $report['exists_in_svn'] ) { + $closed[] = $name; + } + + if ( isset( $report['repo_info'] ) ) { + // Not updated in over 2 years. + if ( isset( $report['repo_info']->last_updated ) ) { + $time_update = new DateTime( $report['repo_info']->last_updated ); + $days_since = ( current_time( 'timestamp' ) - $time_update->getTimestamp() ) / DAY_IN_SECONDS; + if ( $days_since > 730 ) { + $stale[] = $name; + } + } + + // Not tested with current WP major version. + if ( isset( $report['repo_info']->tested ) && ! empty( $report['repo_info']->tested ) ) { + if ( version_compare( $this->get_major_version( $report['repo_info']->tested ), $this->get_major_version( $wp_latest ), '<' ) ) { + $untested[] = $name; + } + } + } + } + + // Nothing to report. + if ( empty( $closed ) && empty( $stale ) && empty( $untested ) ) { + delete_option( 'plugin_report_notified' ); + return; + } + + // Check if we already notified about this exact set of problems. + $current_hash = md5( wp_json_encode( compact( 'closed', 'stale', 'untested' ) ) ); + $notified = get_option( 'plugin_report_notified' ); + + if ( $notified === $current_hash ) { + return; + } + + $this->send_health_check_email( $closed, $stale, $untested ); + update_option( 'plugin_report_notified', $current_hash, false ); + } + + + /** + * Send the health check summary email. + * + * @param array $closed Plugin names that are closed on wp.org. + * @param array $stale Plugin names not updated in 2+ years. + * @param array $untested Plugin names not tested with current WP. + */ + private function send_health_check_email( $closed, $stale, $untested ) { + $total = count( $closed ) + count( $stale ) + count( $untested ); + $body = array(); + + $body[] = __( 'Plugin Report has detected the following issues with your installed plugins:', 'plugin-report' ); + $body[] = ''; + + if ( ! empty( $closed ) ) { + $body[] = __( 'Closed on wordpress.org (no longer receiving updates):', 'plugin-report' ); + foreach ( $closed as $name ) { + $body[] = '- ' . $name; + } + $body[] = ''; + } + + if ( ! empty( $stale ) ) { + $body[] = __( 'Not updated in over 2 years:', 'plugin-report' ); + foreach ( $stale as $name ) { + $body[] = '- ' . $name; + } + $body[] = ''; + } + + if ( ! empty( $untested ) ) { + $body[] = __( 'Not tested with the current major WordPress version:', 'plugin-report' ); + foreach ( $untested as $name ) { + $body[] = '- ' . $name; + } + $body[] = ''; + } + + if ( is_multisite() ) { + $report_url = network_admin_url( 'plugins.php?page=plugin_report' ); + } else { + $report_url = admin_url( 'plugins.php?page=plugin_report' ); + } + + $body[] = sprintf( + /* translators: %s: URL to the Plugin Report page */ + __( 'View the full report: %s', 'plugin-report' ), + $report_url + ); + + if ( '' !== get_option( 'blogname' ) ) { + $site_title = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); + } else { + $site_title = wp_parse_url( home_url(), PHP_URL_HOST ); + } + + $subject = sprintf( + /* translators: 1: Site title, 2: Number of plugins with issues */ + __( '[%1$s] Plugin Report: %2$d plugin(s) need attention', 'plugin-report' ), + $site_title, + $total + ); + + $email = array( + 'to' => get_site_option( 'admin_email' ), + 'subject' => $subject, + 'body' => implode( "\n", $body ), + 'headers' => '', + ); + + /** + * Filters the health check notification email. + * + * @since 2.3.0 + * + * @param array $email { + * Array of email arguments passed to wp_mail(). + * + * @type string $to The email recipient. + * @type string $subject The email subject. + * @type string $body The email body. + * @type string $headers Email headers. + * } + * @param array $closed Plugin names closed on wp.org. + * @param array $stale Plugin names not updated in 2+ years. + * @param array $untested Plugin names not tested with current WP. + */ + $email = apply_filters( 'plugin_report_health_check_email', $email, $closed, $stale, $untested ); + + wp_mail( $email['to'], wp_specialchars_decode( $email['subject'] ), $email['body'], $email['headers'] ); + } + + + /** + * Clear the notification flag so the next health check re-evaluates. + */ + public function clear_notification_flag() { + delete_option( 'plugin_report_notified' ); + } + } // Instantiate the class. $plugin_report_instance = new RT_Plugin_Report(); $plugin_report_instance->init(); + // Schedule/unschedule the health check cron event on activation/deactivation. + register_activation_hook( __FILE__, function () { + if ( ! wp_next_scheduled( 'plugin_report_health_check' ) ) { + wp_schedule_event( time(), 'daily', 'plugin_report_health_check' ); + } + } ); + register_deactivation_hook( __FILE__, function () { + wp_clear_scheduled_hook( 'plugin_report_health_check' ); + delete_option( 'plugin_report_notified' ); + } ); } From 51524ad107cec6b7a93985352e3387b88aac59e8 Mon Sep 17 00:00:00 2001 From: Christoph Daum Date: Tue, 14 Apr 2026 11:36:21 +0200 Subject: [PATCH 2/2] feat(email): add periodic health check notification Extract health check into fully static RT_Plugin_Report_Health_Check class in includes/class-health-check.php. Small testable methods: is_closed(), is_stale(), is_untested(), collect_warnings(), build_email(), already_notified(). Schedule daily WP-Cron event. Deduplicates via hash to avoid repeated emails. Clears notification flag on plugin updates, activation/deactivation, and manual cache clear. --- includes/class-health-check.php | 380 ++++++++++++++++++++++++++++++++ phpstan.neon | 5 +- rt-plugin-report.php | 206 ++--------------- 3 files changed, 398 insertions(+), 193 deletions(-) create mode 100644 includes/class-health-check.php diff --git a/includes/class-health-check.php b/includes/class-health-check.php new file mode 100644 index 0000000..fa45994 --- /dev/null +++ b/includes/class-health-check.php @@ -0,0 +1,380 @@ +check_core_updates(); + $warnings = array( + 'closed' => array(), + 'stale' => array(), + 'untested' => array(), + ); + + foreach ( $plugins as $key => $plugin ) { + $slug = $plugin_report->get_plugin_slug( $key ); + $report = $plugin_report->assemble_plugin_report( $slug ); + + if ( ! $report ) { + continue; + } + + $name = isset( $report['local_info']['Name'] ) ? $report['local_info']['Name'] : $slug; + + if ( self::is_closed( $report ) ) { + $warnings['closed'][] = $name; + } + if ( self::is_stale( $report ) ) { + $warnings['stale'][] = $name; + } + if ( self::is_untested( $report, $wp_latest ) ) { + $warnings['untested'][] = $name; + } + } + + return $warnings; + } + + + /** + * Checks if a plugin report indicates a closed plugin. + * + * @param array $report Plugin report data. + * + * @return bool + */ + public static function is_closed( $report ) { + return isset( $report['repo_error_code'] ) + && 'plugins_api_failed' === $report['repo_error_code'] + && isset( $report['exists_in_svn'] ) + && true === $report['exists_in_svn']; + } + + + /** + * Checks if a plugin report indicates a stale plugin (no update in 2+ years). + * + * @param array $report Plugin report data. + * + * @return bool + */ + public static function is_stale( $report ) { + if ( ! isset( $report['repo_info'] ) || ! isset( $report['repo_info']->last_updated ) ) { + return false; + } + + $time_update = new DateTime( $report['repo_info']->last_updated ); + $days_since = ( current_time( 'timestamp' ) - $time_update->getTimestamp() ) / DAY_IN_SECONDS; + + return $days_since > self::STALE_DAYS; + } + + + /** + * Checks if a plugin report indicates an untested plugin. + * + * @param array $report Plugin report data. + * @param string $wp_latest Latest WP version string. + * + * @return bool + */ + public static function is_untested( $report, $wp_latest ) { + if ( ! isset( $report['repo_info'] ) || ! isset( $report['repo_info']->tested ) || empty( $report['repo_info']->tested ) ) { + return false; + } + + return version_compare( + self::get_major_version( $report['repo_info']->tested ), + self::get_major_version( $wp_latest ), + '<' + ); + } + + + /** + * Returns the major version (first two segments) of a version string. + * + * @param string $version Full version string. + * + * @return string Major version (e.g. "6.8" from "6.8.2"). + */ + private static function get_major_version( $version ) { + $parts = explode( '.', $version ); + array_splice( $parts, 2 ); + return implode( '.', $parts ); + } + + + /** + * Checks if the current warning set was already notified. + * + * @param array $warnings Warning arrays. + * + * @return bool + */ + public static function already_notified( $warnings ) { + return get_option( self::OPTION_NOTIFIED ) === self::hash_warnings( $warnings ); + } + + + /** + * Generates a hash for a warning set. + * + * @param array $warnings Warning arrays. + * + * @return string MD5 hash. + */ + public static function hash_warnings( $warnings ) { + return md5( wp_json_encode( $warnings ) ); + } + + + /** + * Builds the email data array. + * + * @param array $warnings Warning arrays. + * + * @return array { to, subject, body, headers } + */ + public static function build_email( $warnings ) { + $total = count( $warnings['closed'] ) + count( $warnings['stale'] ) + count( $warnings['untested'] ); + + return array( + 'to' => get_site_option( 'admin_email' ), + 'subject' => self::build_email_subject( $total ), + 'body' => self::build_email_body( $warnings ), + 'headers' => '', + ); + } + + + /** + * Builds the email subject line. + * + * @param int $total Total number of warnings. + * + * @return string + */ + private static function build_email_subject( $total ) { + $site_title = get_option( 'blogname' ); + if ( empty( $site_title ) ) { + $site_title = wp_parse_url( home_url(), PHP_URL_HOST ); + } else { + $site_title = wp_specialchars_decode( $site_title, ENT_QUOTES ); + } + + return sprintf( + /* translators: 1: Site title, 2: Number of plugins with issues */ + __( '[%1$s] Plugin Report: %2$d plugin(s) need attention', 'plugin-report' ), + $site_title, + $total + ); + } + + + /** + * Builds the email body text. + * + * @param array $warnings Warning arrays. + * + * @return string + */ + private static function build_email_body( $warnings ) { + $lines = array(); + + $lines[] = __( 'Plugin Report has detected the following issues with your installed plugins:', 'plugin-report' ); + $lines[] = ''; + + if ( ! empty( $warnings['closed'] ) ) { + $lines[] = __( 'Closed on wordpress.org (no longer receiving updates):', 'plugin-report' ); + $lines = array_merge( $lines, self::format_plugin_list( $warnings['closed'] ) ); + $lines[] = ''; + } + + if ( ! empty( $warnings['stale'] ) ) { + $lines[] = __( 'Not updated in over 2 years:', 'plugin-report' ); + $lines = array_merge( $lines, self::format_plugin_list( $warnings['stale'] ) ); + $lines[] = ''; + } + + if ( ! empty( $warnings['untested'] ) ) { + $lines[] = __( 'Not tested with the current major WordPress version:', 'plugin-report' ); + $lines = array_merge( $lines, self::format_plugin_list( $warnings['untested'] ) ); + $lines[] = ''; + } + + $lines[] = sprintf( + /* translators: %s: URL to the Plugin Report page */ + __( 'View the full report: %s', 'plugin-report' ), + self::get_report_url() + ); + + return implode( "\n", $lines ); + } + + + /** + * Formats a list of plugin names as bullet points. + * + * @param array $names Plugin names. + * + * @return array Lines with "- " prefix. + */ + private static function format_plugin_list( $names ) { + $lines = array(); + foreach ( $names as $name ) { + $lines[] = '- ' . $name; + } + return $lines; + } + + + /** + * Gets the URL to the Plugin Report admin page. + * + * @return string + */ + private static function get_report_url() { + if ( is_multisite() ) { + return network_admin_url( 'plugins.php?page=plugin_report' ); + } + return admin_url( 'plugins.php?page=plugin_report' ); + } + + + /** + * Clears the notification flag so the next health check re-evaluates. + */ + public static function clear_notification_flag() { + delete_option( self::OPTION_NOTIFIED ); + } + + + /** + * Schedules the cron event. + */ + public static function schedule() { + if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { + wp_schedule_event( time(), 'daily', self::CRON_HOOK ); + } + } + + + /** + * Unschedules the cron event and cleans up. + */ + public static function unschedule() { + wp_clear_scheduled_hook( self::CRON_HOOK ); + delete_option( self::OPTION_NOTIFIED ); + } +} diff --git a/phpstan.neon b/phpstan.neon index 22ed929..e07dbc6 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -5,5 +5,8 @@ parameters: level: 5 paths: - rt-plugin-report.php + - includes/ ignoreErrors: - - '#Path in require_once\(\) "\./wp-admin/includes/plugin-install\.php" is not a file or it does not exist#' + - + message: '#Path in require_once\(\) "\./wp-admin/includes/plugin-install\.php" is not a file or it does not exist#' + reportUnmatched: false diff --git a/rt-plugin-report.php b/rt-plugin-report.php index 2ee0b78..ff04eab 100644 --- a/rt-plugin-report.php +++ b/rt-plugin-report.php @@ -18,7 +18,11 @@ } -if ( is_admin() && ! class_exists( 'RT_Plugin_Report' ) ) { +define( 'RT_PLUGIN_REPORT_FILE', __FILE__ ); + +require_once __DIR__ . '/includes/class-health-check.php'; + +if ( ! class_exists( 'RT_Plugin_Report' ) ) { /** * Plugin Report main class. @@ -60,11 +64,8 @@ public function init() { add_action( 'wp_ajax_rt_get_plugin_info', array( $this, 'get_plugin_info' ) ); // Hook into the WP Upgrader to selectively delete cache items. add_action( 'upgrader_process_complete', array( $this, 'upgrade_delete_cache_items' ), 10, 2 ); - // Periodic health check for abandoned/problematic plugins. - add_action( 'plugin_report_health_check', array( $this, 'run_health_check' ) ); - // Reset notification flag when plugins change. - add_action( 'activated_plugin', array( $this, 'clear_notification_flag' ) ); - add_action( 'deactivated_plugin', array( $this, 'clear_notification_flag' ) ); + // Initialize periodic health check. + RT_Plugin_Report_Health_Check::init( $this ); } @@ -220,7 +221,7 @@ private function get_plugin_slugs() { * * @param string $file Plugin file path. */ - private function get_plugin_slug( $file ) { + public function get_plugin_slug( $file ) { if ( strpos( $file, '/' ) !== false ) { $parts = explode( '/', $file ); } else { @@ -283,7 +284,7 @@ public function get_plugin_info() { * * @param string $slug Plugin slug. */ - private function assemble_plugin_report( $slug ) { + public function assemble_plugin_report( $slug ) { if ( ! empty( $slug ) ) { $report = array(); $cache_key = $this->create_cache_key( $slug ); @@ -666,7 +667,7 @@ private function get_timediff_risk_classname( $time_diff ) { * Get the latest available WordPress version using WP core functions * This way, we don't need to do any API calls. WP check this periodically anyway. */ - private function check_core_updates() { + public function check_core_updates() { global $wp_version; $update = get_preferred_from_update_core(); // Bail out of no valid response, or false. @@ -750,8 +751,8 @@ private function clear_cache() { $slug = $this->get_plugin_slug( $key ); $this->clear_cache_item( $slug ); } - // Reset notification flag so the next health check re-evaluates. - $this->clear_notification_flag(); + // Signal that cached plugin data has changed. + do_action( 'plugin_report_cache_cleared' ); } @@ -777,178 +778,9 @@ public function upgrade_delete_cache_items( $upgrader, $data ) { $slug = $this->get_plugin_slug( $value ); $this->clear_cache_item( $slug ); } - // Reset notification flag so the next health check re-evaluates. - $this->clear_notification_flag(); - } - } - - /** - * Run the periodic health check for abandoned/problematic plugins. - * Sends an email to the site admin when issues are found. - */ - public function run_health_check() { - // Ensure plugins_api is available. - if ( ! function_exists( 'plugins_api' ) ) { - require_once ABSPATH . 'wp-admin/includes/plugin-install.php'; - } - - $plugins = get_plugins(); - $wp_latest = $this->check_core_updates(); - $closed = array(); - $stale = array(); - $untested = array(); - - foreach ( $plugins as $key => $plugin ) { - $slug = $this->get_plugin_slug( $key ); - $report = $this->assemble_plugin_report( $slug ); - - if ( ! $report ) { - continue; - } - - $name = isset( $report['local_info']['Name'] ) ? $report['local_info']['Name'] : $slug; - - // Closed on wp.org. - if ( isset( $report['repo_error_code'] ) && 'plugins_api_failed' === $report['repo_error_code'] && isset( $report['exists_in_svn'] ) && true === $report['exists_in_svn'] ) { - $closed[] = $name; - } - - if ( isset( $report['repo_info'] ) ) { - // Not updated in over 2 years. - if ( isset( $report['repo_info']->last_updated ) ) { - $time_update = new DateTime( $report['repo_info']->last_updated ); - $days_since = ( current_time( 'timestamp' ) - $time_update->getTimestamp() ) / DAY_IN_SECONDS; - if ( $days_since > 730 ) { - $stale[] = $name; - } - } - - // Not tested with current WP major version. - if ( isset( $report['repo_info']->tested ) && ! empty( $report['repo_info']->tested ) ) { - if ( version_compare( $this->get_major_version( $report['repo_info']->tested ), $this->get_major_version( $wp_latest ), '<' ) ) { - $untested[] = $name; - } - } - } - } - - // Nothing to report. - if ( empty( $closed ) && empty( $stale ) && empty( $untested ) ) { - delete_option( 'plugin_report_notified' ); - return; - } - - // Check if we already notified about this exact set of problems. - $current_hash = md5( wp_json_encode( compact( 'closed', 'stale', 'untested' ) ) ); - $notified = get_option( 'plugin_report_notified' ); - - if ( $notified === $current_hash ) { - return; - } - - $this->send_health_check_email( $closed, $stale, $untested ); - update_option( 'plugin_report_notified', $current_hash, false ); - } - - - /** - * Send the health check summary email. - * - * @param array $closed Plugin names that are closed on wp.org. - * @param array $stale Plugin names not updated in 2+ years. - * @param array $untested Plugin names not tested with current WP. - */ - private function send_health_check_email( $closed, $stale, $untested ) { - $total = count( $closed ) + count( $stale ) + count( $untested ); - $body = array(); - - $body[] = __( 'Plugin Report has detected the following issues with your installed plugins:', 'plugin-report' ); - $body[] = ''; - - if ( ! empty( $closed ) ) { - $body[] = __( 'Closed on wordpress.org (no longer receiving updates):', 'plugin-report' ); - foreach ( $closed as $name ) { - $body[] = '- ' . $name; - } - $body[] = ''; + // Signal that cached plugin data has changed. + do_action( 'plugin_report_cache_cleared' ); } - - if ( ! empty( $stale ) ) { - $body[] = __( 'Not updated in over 2 years:', 'plugin-report' ); - foreach ( $stale as $name ) { - $body[] = '- ' . $name; - } - $body[] = ''; - } - - if ( ! empty( $untested ) ) { - $body[] = __( 'Not tested with the current major WordPress version:', 'plugin-report' ); - foreach ( $untested as $name ) { - $body[] = '- ' . $name; - } - $body[] = ''; - } - - if ( is_multisite() ) { - $report_url = network_admin_url( 'plugins.php?page=plugin_report' ); - } else { - $report_url = admin_url( 'plugins.php?page=plugin_report' ); - } - - $body[] = sprintf( - /* translators: %s: URL to the Plugin Report page */ - __( 'View the full report: %s', 'plugin-report' ), - $report_url - ); - - if ( '' !== get_option( 'blogname' ) ) { - $site_title = wp_specialchars_decode( get_option( 'blogname' ), ENT_QUOTES ); - } else { - $site_title = wp_parse_url( home_url(), PHP_URL_HOST ); - } - - $subject = sprintf( - /* translators: 1: Site title, 2: Number of plugins with issues */ - __( '[%1$s] Plugin Report: %2$d plugin(s) need attention', 'plugin-report' ), - $site_title, - $total - ); - - $email = array( - 'to' => get_site_option( 'admin_email' ), - 'subject' => $subject, - 'body' => implode( "\n", $body ), - 'headers' => '', - ); - - /** - * Filters the health check notification email. - * - * @since 2.3.0 - * - * @param array $email { - * Array of email arguments passed to wp_mail(). - * - * @type string $to The email recipient. - * @type string $subject The email subject. - * @type string $body The email body. - * @type string $headers Email headers. - * } - * @param array $closed Plugin names closed on wp.org. - * @param array $stale Plugin names not updated in 2+ years. - * @param array $untested Plugin names not tested with current WP. - */ - $email = apply_filters( 'plugin_report_health_check_email', $email, $closed, $stale, $untested ); - - wp_mail( $email['to'], wp_specialchars_decode( $email['subject'] ), $email['body'], $email['headers'] ); - } - - - /** - * Clear the notification flag so the next health check re-evaluates. - */ - public function clear_notification_flag() { - delete_option( 'plugin_report_notified' ); } } @@ -957,14 +789,4 @@ public function clear_notification_flag() { $plugin_report_instance = new RT_Plugin_Report(); $plugin_report_instance->init(); - // Schedule/unschedule the health check cron event on activation/deactivation. - register_activation_hook( __FILE__, function () { - if ( ! wp_next_scheduled( 'plugin_report_health_check' ) ) { - wp_schedule_event( time(), 'daily', 'plugin_report_health_check' ); - } - } ); - register_deactivation_hook( __FILE__, function () { - wp_clear_scheduled_hook( 'plugin_report_health_check' ); - delete_option( 'plugin_report_notified' ); - } ); }