From 00e68756724e4c49921f06df41b021df87492537 Mon Sep 17 00:00:00 2001 From: Faisal Ahammad Date: Wed, 9 Sep 2026 23:29:55 +0300 Subject: [PATCH] fix(alerts): stop non-stop email alerts Throttle email alerts to one send per 5 minutes per alert, and remove alert configuration, options, and scheduled jobs on plugin uninstall. Repeating matching records no longer flood the inbox, and deleting the plugin no longer leaves enabled alerts behind to fire again. Fixes #1855 --- alerts/class-alert-type-email.php | 61 ++++++++ tests/phpunit/test-class-alert-type-email.php | 139 ++++++++++++++++++ uninstall.php | 127 ++++++++++++++++ 3 files changed, 327 insertions(+) create mode 100644 tests/phpunit/test-class-alert-type-email.php create mode 100644 uninstall.php diff --git a/alerts/class-alert-type-email.php b/alerts/class-alert-type-email.php index 1d1e0cc1a..0a35c5ba4 100644 --- a/alerts/class-alert-type-email.php +++ b/alerts/class-alert-type-email.php @@ -23,6 +23,11 @@ class Alert_Type_Email extends Alert_Type { */ public $name = 'Email'; + /** + * Meta key that stores the last time an alert sent an email. + */ + const LAST_SENT_META_KEY = '_wp_stream_email_last_sent'; + /** * Alert type slug * @@ -62,6 +67,10 @@ public function __construct( $plugin ) { * @return void */ public function alert( $record_id, $recordarr, $alert ) { + if ( ! $this->is_send_allowed( $alert ) ) { + return; + } + $options = wp_parse_args( $alert->alert_meta, array( @@ -120,9 +129,61 @@ public function alert( $record_id, $recordarr, $alert ) { $edit_alert_link = admin_url( 'edit.php?post_type=wp_stream_alerts#post-' . $alert->ID ); $message .= __( 'Edit Alert', 'stream' ) . "\n<$edit_alert_link>"; + $this->update_last_sent( $alert ); + wp_mail( $options['email_recipient'], $options['email_subject'], $message ); } + /** + * Checks if the throttle allows this alert to send another email. + * + * @param Alert $alert Alert object. + * @return bool + */ + public function is_send_allowed( $alert ) { + $interval = $this->get_throttle_interval(); + + if ( $interval <= 0 ) { + return true; + } + + $last_sent = (int) get_post_meta( $alert->ID, self::LAST_SENT_META_KEY, true ); + + return ( time() - $last_sent ) >= $interval; + } + + /** + * Stores the time of the last email sent by this alert. + * + * @param Alert $alert Alert object. + * @return void + */ + public function update_last_sent( $alert ) { + if ( empty( $alert->ID ) ) { + return; + } + + update_post_meta( $alert->ID, self::LAST_SENT_META_KEY, time() ); + } + + /** + * Returns the minimum number of seconds between two emails sent by one alert. + * + * @return int + */ + public function get_throttle_interval() { + /** + * Filters the minimum number of seconds between two emails sent by the same alert. + * + * Set it to 0 to send an email for every matching record. + * + * @param int $interval Minimum number of seconds between two emails. Default 300. + */ + $interval = (int) apply_filters( 'wp_stream_alert_email_throttle', 300 ); + + return $interval; + } + /** * Displays a settings form for the alert type * diff --git a/tests/phpunit/test-class-alert-type-email.php b/tests/phpunit/test-class-alert-type-email.php new file mode 100644 index 000000000..8e6b521e0 --- /dev/null +++ b/tests/phpunit/test-class-alert-type-email.php @@ -0,0 +1,139 @@ +alert_type = new Alert_Type_Email( $this->plugin ); + + if ( empty( self::$alert_post_id ) || ! get_post( self::$alert_post_id ) ) { + self::$alert_post_id = self::factory()->post->create( + array( + 'post_type' => Alerts::POST_TYPE, + 'post_status' => Alerts::STATUS_ENABLED, + ) + ); + } + } + + public function tearDown(): void { + remove_all_filters( 'wp_stream_alert_email_throttle' ); + delete_post_meta( self::$alert_post_id, Alert_Type_Email::LAST_SENT_META_KEY ); + + parent::tearDown(); + } + + public function test_send_allowed_when_no_email_was_sent_before() { + $alert = $this->plugin->alerts->get_alert( self::$alert_post_id ); + + $this->assertTrue( $this->alert_type->is_send_allowed( $alert ) ); + } + + public function test_send_blocked_within_throttle_interval() { + $alert = $this->plugin->alerts->get_alert( self::$alert_post_id ); + + $this->alert_type->update_last_sent( $alert ); + + $this->assertFalse( $this->alert_type->is_send_allowed( $alert ) ); + } + + public function test_send_allowed_after_throttle_interval() { + $alert = $this->plugin->alerts->get_alert( self::$alert_post_id ); + + update_post_meta( self::$alert_post_id, Alert_Type_Email::LAST_SENT_META_KEY, time() - 301 ); + + $this->assertTrue( $this->alert_type->is_send_allowed( $alert ) ); + } + + public function test_send_allowed_with_zero_throttle() { + add_filter( 'wp_stream_alert_email_throttle', '__return_zero' ); + + $alert = $this->plugin->alerts->get_alert( self::$alert_post_id ); + + $this->alert_type->update_last_sent( $alert ); + + $this->assertTrue( $this->alert_type->is_send_allowed( $alert ) ); + } + + public function test_alert_sends_one_email_and_throttles_the_second() { + reset_phpmailer_instance(); + $mailer = tests_retrieve_phpmailer_instance(); + + $alert = $this->plugin->alerts->get_alert( self::$alert_post_id ); + $alert->alert_meta = array_merge( + (array) $alert->alert_meta, + array( + 'email_recipient' => 'admin@example.org', + 'email_subject' => 'Stream alert', + ) + ); + + $this->alert_type->alert( 0, $this->dummy_record_data(), $alert ); + + $this->assertCount( 1, $mailer->mock_sent ); + + // A second trigger inside the throttle window sends no further email. + $this->alert_type->alert( 0, $this->dummy_record_data(), $alert ); + + $this->assertCount( 1, $mailer->mock_sent ); + } + + public function test_alert_sends_every_email_with_zero_throttle() { + add_filter( 'wp_stream_alert_email_throttle', '__return_zero' ); + + reset_phpmailer_instance(); + $mailer = tests_retrieve_phpmailer_instance(); + + $alert = $this->plugin->alerts->get_alert( self::$alert_post_id ); + $alert->alert_meta = array_merge( + (array) $alert->alert_meta, + array( + 'email_recipient' => 'admin@example.org', + 'email_subject' => 'Stream alert', + ) + ); + + $this->alert_type->alert( 0, $this->dummy_record_data(), $alert ); + $this->alert_type->alert( 0, $this->dummy_record_data(), $alert ); + + $this->assertCount( 2, $mailer->mock_sent ); + } + + private function dummy_record_data() { + return array( + 'object_id' => null, + 'site_id' => '1', + 'blog_id' => get_current_blog_id(), + 'user_id' => '1', + 'user_role' => 'administrator', + 'created' => gmdate( 'Y-m-d H:i:s' ), + 'summary' => '"Hello Dave" plugin activated', + 'ip' => '192.168.0.1', + 'connector' => 'installer', + 'context' => 'plugins', + 'action' => 'activated', + ); + } +} diff --git a/uninstall.php b/uninstall.php new file mode 100644 index 000000000..13bc621d9 --- /dev/null +++ b/uninstall.php @@ -0,0 +1,127 @@ +get_col( + $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE post_type = %s", 'wp_stream_alerts' ) + ); + foreach ( $alert_ids as $alert_id ) { + wp_delete_post( (int) $alert_id, true ); + } + + // Remove all Stream options, including transients and legacy keys. + // delete_option() invalidates the alloptions cache on every site. + $option_names = $wpdb->get_col( + $wpdb->prepare( + "SELECT option_name FROM {$wpdb->options} WHERE option_name LIKE %s OR option_name LIKE %s", + $wpdb->esc_like( 'wp_stream' ) . '%', + $wpdb->esc_like( '_transient_wp_stream' ) . '%' + ) + ); + foreach ( $option_names as $option_name ) { + delete_option( $option_name ); + } + + // Remove the legacy WP-Cron purge event for this site. + wp_clear_scheduled_hook( 'wp_stream_auto_purge' ); + + // Remove the recurring purge / reset jobs. WP-Cron events live in the + // per-site cron option. Action Scheduler tables are per-site, and the + // AS API is only loaded when another plugin or Stream itself provides + // it, so the tables are cleaned directly as a fallback. + $stream_scheduled_hooks = array( + 'stream_auto_purge_action', + 'stream_auto_purge_batch_action', + 'stream_auto_purge_reaper_action', + 'stream_erase_large_records_action', + ); + if ( function_exists( 'as_unschedule_all_actions' ) ) { + foreach ( $stream_scheduled_hooks as $stream_scheduled_hook ) { + as_unschedule_all_actions( $stream_scheduled_hook ); + } + } else { + foreach ( $stream_scheduled_hooks as $stream_scheduled_hook ) { + wp_clear_scheduled_hook( $stream_scheduled_hook ); + } + } + } +} + +if ( is_multisite() ) { + $stream_site_ids = get_sites( + array( + 'fields' => 'ids', + 'number' => 0, + ) + ); + foreach ( $stream_site_ids as $stream_site_id ) { + switch_to_blog( (int) $stream_site_id ); + wp_stream_uninstall_delete_site_data(); + restore_current_blog(); + } + + // Network options live outside the per-site loop. delete_site_option() + // covers wp_stream, wp_stream_network, wp_stream_db*, and any leftover + // Stream keys that were written via update_site_option(). + $stream_network_option_names = $wpdb->get_col( + $wpdb->prepare( + "SELECT meta_key FROM {$wpdb->sitemeta} WHERE meta_key LIKE %s OR meta_key LIKE %s", + $wpdb->esc_like( 'wp_stream' ) . '%', + $wpdb->esc_like( '_site_transient_wp_stream' ) . '%' + ) + ); + foreach ( $stream_network_option_names as $stream_network_option_name ) { + delete_site_option( $stream_network_option_name ); + } +} else { + wp_stream_uninstall_delete_site_data(); + // delete_site_option() works on single site too. + delete_site_option( 'wp_stream_network' ); + // Network options that use update_site_option outside the settings class. + delete_site_option( 'wp_stream_db' ); + delete_site_option( 'wp_stream_db_connectors' ); + delete_site_option( 'wp_stream_db_registered_connectors' ); +} + +// User meta is global, so it is cleaned once. delete_user_meta() keeps the +// user meta cache in sync, unlike a direct query against the table. +global $wpdb; +$stream_user_meta_keys = $wpdb->get_results( + $wpdb->prepare( + "SELECT user_id, meta_key FROM {$wpdb->usermeta} WHERE meta_key LIKE %s OR meta_key LIKE %s OR meta_key IN ( 'stream_live_update_records', 'stream_last_read', 'stream_unread_count', 'stream_user_feed_key' )", + '%' . $wpdb->esc_like( 'wp_stream' ) . '%', + '%' . $wpdb->esc_like( 'edit_stream_per_page' ) + ) +); +if ( $stream_user_meta_keys ) { + foreach ( $stream_user_meta_keys as $stream_user_meta_key ) { + delete_user_meta( (int) $stream_user_meta_key->user_id, $stream_user_meta_key->meta_key ); + } +}