schedule_next_pending( "{$this->wpdb->prefix}senti_notifications" ); } public static function deactivate(): void { wp_clear_scheduled_hook( self::ACTION ); wp_clear_scheduled_hook( self::FALLBACK ); } /** * @internal */ public function process(): void { $table = "{$this->wpdb->prefix}senti_notifications"; $this->reclaim_stale( $table ); $query = $this->wpdb->prepare( 'SELECT notification.id, notification.subscription_id, notification.event_type, notification.subject_id, notification.attempt_count, subscription.email FROM %i notification INNER JOIN %i subscription ON subscription.id = notification.subscription_id WHERE notification.state = %s AND notification.scheduled_gmt <= UTC_TIMESTAMP() AND subscription.state = %s ORDER BY notification.id LIMIT %d', $table, $this->wpdb->prefix . 'senti_subscriptions', 'pending', 'active', self::LIMIT ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared immediately above. $rows = is_string( $query ) ? $this->wpdb->get_results( $query ) ?: [] : []; foreach ( $rows as $row ) { $claimed_at = current_time( 'mysql', true ); if ( $this->wpdb->update( $table, [ 'state' => 'sending', 'scheduled_gmt' => $claimed_at, ], [ 'id' => (int) $row->id, 'state' => 'pending', ], [ '%s' ], [ '%d', '%s' ] ) !== 1 ) { continue; } $permalink = get_permalink( (int) $row->subject_id ) ?: home_url( '/' ); $unsub_url = $this->subscriptions->unsubscribe_url( (int) $row->subscription_id, (string) $row->email ); $title = get_the_title( (int) $row->subject_id ); $site_name = wp_specialchars_decode( get_bloginfo( 'name' ), ENT_QUOTES ); if ( (string) $row->event_type === 'feedback_status' ) { $status_terms = wp_get_object_terms( (int) $row->subject_id, Identifiers::TAXONOMY_FEEDBACK_STATUS, [ 'fields' => 'names' ] ); $status_name = ! is_wp_error( $status_terms ) && $status_terms !== [] ? (string) $status_terms[0] : __( 'Updated', 'senti-feedback' ); $subject = sprintf( /* translators: 1: Site name. 2: Feedback title. */ __( '[%1$s] Feedback status update: %2$s', 'senti-feedback' ), $site_name, $title ); $message = sprintf( /* translators: 1: Feedback title. 2: New status. 3: Feedback URL. 4: Unsubscribe URL. */ __( "An update has been made to a request you are following:\n\nTitle: %1\$s\nStatus: %2\$s\n\nView details: %3\$s\n\n---\nUnsubscribe: %4\$s", 'senti-feedback' ), $title, $status_name, $permalink, $unsub_url ); } else { $subject = sprintf( /* translators: 1: Site name. 2: Release title. */ __( '[%1$s] New product release: %2$s', 'senti-feedback' ), $site_name, $title ); $message = sprintf( /* translators: 1: Release title. 2: Release URL. 3: Unsubscribe URL. */ __( "A new product release has been published:\n\n%1\$s\n\nView update: %2\$s\n\n---\nUnsubscribe: %3\$s", 'senti-feedback' ), $title, $permalink, $unsub_url ); } $sent = $this->mailer->send( (string) $row->email, $subject, $message ); if ( $sent ) { $this->wpdb->update( $table, [ 'state' => 'sent', 'sent_gmt' => current_time( 'mysql', true ), 'last_error' => null, ], [ 'id' => (int) $row->id ], [ '%s', '%s' ], [ '%d' ] ); continue; } $attempts = (int) $row->attempt_count + 1; $state = $attempts >= self::MAX_ATTEMPTS ? 'failed' : 'pending'; $delay = min( (int) ( 5 * ( 2 ** ( $attempts - 1 ) ) ), 30 ) * MINUTE_IN_SECONDS; $this->wpdb->update( $table, [ 'state' => $state, 'attempt_count' => $attempts, 'scheduled_gmt' => gmdate( 'Y-m-d H:i:s', time() + $delay ), 'last_error' => 'wp_mail failed', ], [ 'id' => (int) $row->id ], [ '%s', '%d', '%s', '%s' ], [ '%d' ] ); } $this->schedule_next_pending( $table ); } private function reclaim_stale( string $table ): void { $cutoff = gmdate( 'Y-m-d H:i:s', time() - 15 * MINUTE_IN_SECONDS ); $query = $this->wpdb->prepare( 'UPDATE %i SET state = CASE WHEN attempt_count + 1 >= %d THEN %s ELSE %s END, attempt_count = attempt_count + 1, scheduled_gmt = %s, last_error = %s WHERE state = %s AND scheduled_gmt < %s', $table, self::MAX_ATTEMPTS, 'failed', 'pending', gmdate( 'Y-m-d H:i:s', time() + 5 * MINUTE_IN_SECONDS ), 'delivery worker timed out', 'sending', $cutoff ); if ( is_string( $query ) ) { // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared immediately above. $this->wpdb->query( $query ); } } private function schedule_next_pending( string $table ): void { $query = $this->wpdb->prepare( 'SELECT MIN(scheduled_gmt) FROM %i WHERE state = %s', $table, 'pending' ); // phpcs:ignore WordPress.DB.PreparedSQL.NotPrepared -- $query is prepared immediately above. $next = is_string( $query ) ? $this->wpdb->get_var( $query ) : null; if ( ! is_string( $next ) || $next === '' ) { return; } $timestamp = max( time() + MINUTE_IN_SECONDS, strtotime( $next . ' UTC' ) ?: time() + MINUTE_IN_SECONDS ); $scheduled = wp_next_scheduled( self::ACTION ); if ( $scheduled !== false && $scheduled <= $timestamp ) { return; } wp_clear_scheduled_hook( self::ACTION ); wp_schedule_single_event( $timestamp, self::ACTION ); } }