Below is a differential between the unpatched vulnerable code and the patched update, for reference.
--- 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'], " tnr x0B"'"))
+ : '';
+
// 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