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 ); + } +}