From d0d7f87771f695678c783f9a1b8f66f6d38a90e9 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 2 Feb 2026 10:51:47 -0500 Subject: [PATCH 01/14] Add GitHub Updater plugin headers for automatic updates Add `GitHub Plugin URI` and `Primary Branch` headers to enable automatic plugin updates via the Git Updater plugin. Also fill in missing plugin metadata (Description, Version, License, Requires PHP, Requires at least) and correct the Plugin URI to point to the current repository. --- fluent-crm-custom-features.php | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 74d52bb..30bdb6d 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -1,16 +1,17 @@ Date: Mon, 2 Feb 2026 11:03:33 -0500 Subject: [PATCH 02/14] Add vendor-prefixed .gitkeep to fix composer classmap scan error The classmap autoload entry references the vendor-prefixed directory, but the directory wasn't tracked in git, causing composer to fail with "Could not scan for classes inside vendor-prefixed". Track the directory via .gitkeep so it exists on fresh clones. --- .gitignore | 3 ++- vendor-prefixed/.gitkeep | 0 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 vendor-prefixed/.gitkeep diff --git a/.gitignore b/.gitignore index 21bca1b..8607472 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Dependencies /vendor/ -/vendor-prefixed/ +/vendor-prefixed/* +!/vendor-prefixed/.gitkeep /node_modules/ # Composer diff --git a/vendor-prefixed/.gitkeep b/vendor-prefixed/.gitkeep new file mode 100644 index 0000000..e69de29 From eb1326b0e7b526b53c622738d9288b6683279b69 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 2 Feb 2026 11:06:44 -0500 Subject: [PATCH 03/14] Fix fatal error: register() callbacks reference non-existent camelCase methods The register() method hooked camelCase method names (e.g., addEventTrackingFilterOptions) but the actual methods use snake_case (e.g., add_event_tracking_filter_options). This caused a fatal TypeError on every page load where FluentCRM triggers these filters. --- classes/JSONEventTrackingHandler.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/classes/JSONEventTrackingHandler.php b/classes/JSONEventTrackingHandler.php index 2f001f5..0b49fc2 100644 --- a/classes/JSONEventTrackingHandler.php +++ b/classes/JSONEventTrackingHandler.php @@ -33,22 +33,22 @@ class JSONEventTrackingHandler { */ public function register() { // Handle AJAX Property Name Lookups. - add_filter( 'fluentcrm_ajax_options_event_tracking_json_props', [ $this, 'getEventTrackingPropsOptions' ], 10, 1 ); + add_filter( 'fluentcrm_ajax_options_event_tracking_json_props', [ $this, 'get_event_tracking_props_options' ], 10, 1 ); // Apply conditional event rules. - add_filter( 'fluentcrm_contacts_filter_event_tracking', [ $this, 'applyEventTrackingFilter' ], 10, 2 ); + add_filter( 'fluentcrm_contacts_filter_event_tracking', [ $this, 'apply_event_tracking_filter' ], 10, 2 ); // Show JSON event widget. - add_filter( 'fluent_crm/subscriber_info_widgets', [ $this, 'addSubscriberInfoWidgets' ], 11, 2 ); - add_filter( 'fluent_crm/subscriber_info_widget_event_tracking', [ $this, 'addSubscriberInfoWidgets' ], 11, 2 ); + add_filter( 'fluent_crm/subscriber_info_widgets', [ $this, 'add_subscriber_info_widgets' ], 11, 2 ); + add_filter( 'fluent_crm/subscriber_info_widget_event_tracking', [ $this, 'add_subscriber_info_widgets' ], 11, 2 ); // Add custom rule types. - add_filter( 'fluentcrm_advanced_filter_options', [ $this, 'addEventTrackingFilterOptions' ], 11, 1 ); - add_filter( 'fluent_crm/event_tracking_condition_groups', [ $this, 'addEventTrackingConditionOptions' ], 11, 1 ); + add_filter( 'fluentcrm_advanced_filter_options', [ $this, 'add_event_tracking_filter_options' ], 11, 1 ); + add_filter( 'fluent_crm/event_tracking_condition_groups', [ $this, 'add_event_tracking_condition_options' ], 11, 1 ); - // Remove. - add_filter( 'fluentcrm_automation_conditions_assess_event_tracking_objects', [ $this, 'assessEventObjectTrackingConditions' ], 10, 3 ); - add_filter( 'fluentcrm_contacts_filter_event_tracking_objects', [ $this, 'applyEventTrackingFilter' ], 10, 2 ); + // Assess event object tracking conditions. + add_filter( 'fluentcrm_automation_conditions_assess_event_tracking_objects', [ $this, 'assess_event_object_tracking_conditions' ], 10, 3 ); + add_filter( 'fluentcrm_contacts_filter_event_tracking_objects', [ $this, 'apply_event_tracking_filter' ], 10, 2 ); } /** From d4c1a650a358ba1ac1d4572c0523096004336afe Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 2 Feb 2026 11:14:41 -0500 Subject: [PATCH 04/14] Fix security vulnerabilities, bugs, and dead code across plugin MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Security fixes: - Add authentication to REST endpoint (require manage_options capability) - Add date parameter validation to prevent malformed input - Sanitize $prop_name in SQL queries to prevent SQL injection - Use bound parameters for all whereRaw LIKE queries - Sanitize $_GET params before forwarding in SmartLink redirects Bug fixes: - Remove stray break in not_contains filter that skipped remaining filters - Fix wrong variable check ($item_value → $trimmed_values) in UpdateContactPropertyAction::formatCustomFieldValues - Fix trailing ?/& appended to redirect URLs when no query params exist - Remove empty switch statement (dead code from incomplete implementation) Cleanup: - Remove hardcoded debug email (daniel@code-atlantic.com) from event tracking - Remove no-op gettingAction() override in RandomWaitTimeAction - Remove dead code: commented-out hook, unused add_custom_dashboard_metrics(), placeholder register_custom_report/render_custom_report (Subscriber::all() memory bomb) - Remove Carbon dependency from REST endpoint (unnecessary) - Prefix remaining global functions with customcrm_ to avoid collisions --- classes/Actions/RandomWaitTimeAction.php | 28 ---- .../Actions/UpdateContactPropertyAction.php | 11 +- classes/JSONEventTrackingHandler.php | 10 +- classes/SmartLinkHandler.php | 19 +-- fluent-crm-custom-features.php | 121 +++++++----------- 5 files changed, 64 insertions(+), 125 deletions(-) diff --git a/classes/Actions/RandomWaitTimeAction.php b/classes/Actions/RandomWaitTimeAction.php index 5016178..1997daa 100644 --- a/classes/Actions/RandomWaitTimeAction.php +++ b/classes/Actions/RandomWaitTimeAction.php @@ -82,20 +82,6 @@ public function savingAction( $sequence, $funnel ) { return $sequence; } - /** - * Get the action settings. - * - * @param array $sequence The sequence settings. - * @param array $funnel The funnel settings. - * - * @return array - */ - public function gettingAction( $sequence, $funnel ) { - $sequence = parent::gettingAction( $sequence, $funnel ); - - return $sequence; - } - /** * Get the block fields for the action. * @@ -199,20 +185,6 @@ public function setDelayInSeconds( $delay_in_seconds, $settings, $sequence, $fun $wait_times = $wait_times * 60 * 60 * 24 * ( 365 / 12 ); } - if ( $wait_times !== $delay_in_seconds ) { - // Track the random time as an event for debugging. - \FluentCrmApi( 'event_tracker' )->track( [ - 'event_key' => 'random_wait_time', // Required - 'title' => 'Randomized Wait Time', // Required - 'value' => wp_json_encode([ - 'next_sequence' => gmdate( 'Y-m-d H:i:s', time() + $wait_times ), - 'delay' => $wait_times, - ]), - 'email' => 'daniel@code-atlantic.com', - 'provider' => 'debug', // If left empty, 'custom' will be added. - ], false ); - } - return $wait_times; } } diff --git a/classes/Actions/UpdateContactPropertyAction.php b/classes/Actions/UpdateContactPropertyAction.php index 7ae16b5..510b062 100644 --- a/classes/Actions/UpdateContactPropertyAction.php +++ b/classes/Actions/UpdateContactPropertyAction.php @@ -309,13 +309,10 @@ public function formatCustomFieldValues( $values, $fields = [] ) { $is_array_type = Arr::get( $fields, $value_key . '.type' ) === 'checkbox' || Arr::get( $fields, $value_key . '.type' ) === 'select-multi'; if ( ! is_array( $value ) && $is_array_type ) { - $item_values = explode( ',', $value ); - $trimmedvalues = []; - foreach ( $item_values as $item_value ) { - $trimmedvalues[] = trim( $item_value ); - } - if ( $item_value ) { - $values[ $value_key ] = $trimmedvalues; + $item_values = explode( ',', $value ); + $trimmed_values = array_map( 'trim', $item_values ); + if ( $trimmed_values ) { + $values[ $value_key ] = $trimmed_values; } } } diff --git a/classes/JSONEventTrackingHandler.php b/classes/JSONEventTrackingHandler.php index 0b49fc2..860fde2 100644 --- a/classes/JSONEventTrackingHandler.php +++ b/classes/JSONEventTrackingHandler.php @@ -185,8 +185,9 @@ public static function apply_event_tracking_filter( $query, $filters ) { continue; } - switch ( $prop_type ) { - case 'int': + // Sanitize prop_name to prevent SQL injection — only allow alphanumeric and underscores. + if ( ! preg_match( '/^[a-zA-Z0-9_]+$/', $prop_name ) ) { + continue; } $operator = $filter['operator']; @@ -270,7 +271,7 @@ public static function apply_event_tracking_filter( $query, $filters ) { $q ->where( 'event_key', $event_key_var ) - ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE '%{$escaped_value}%'" ); + ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE ?", [ '%' . $escaped_value . '%' ] ); } ); } elseif ( 'not_contains' === $operator ) { @@ -285,10 +286,9 @@ public static function apply_event_tracking_filter( $query, $filters ) { $q ->where( 'event_key', $event_key_var ) - ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE '%{$escaped_value}%'" ); + ->whereRaw( "JSON_EXTRACT(`value`, '$.{$prop_name}') LIKE ?", [ '%' . $escaped_value . '%' ] ); } ); - break; } } } diff --git a/classes/SmartLinkHandler.php b/classes/SmartLinkHandler.php index 4d9fa9f..dbf39e0 100644 --- a/classes/SmartLinkHandler.php +++ b/classes/SmartLinkHandler.php @@ -91,10 +91,13 @@ public function handleClick( $slug, $contact = null ) { * @return string The target URL with query parameters preserved. */ public function getTargetUrl( $smart_link, $contact ) { - $ignored_params = [ 'fluentcrm', 'route', 'slug' ]; // Define the parameters to ignore. + $ignored_params = [ 'fluentcrm', 'route', 'slug' ]; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading query params for smart link redirect, nonce not applicable. - $query_params = array_diff_key( $_GET, array_flip( $ignored_params ) ); // Filter out ignored parameters. - $query_string = http_build_query( $query_params ); // Build the query string from remaining parameters. + $query_params = array_diff_key( $_GET, array_flip( $ignored_params ) ); + + // Sanitize all forwarded query parameters. + $query_params = array_map( 'sanitize_text_field', $query_params ); + $query_string = http_build_query( $query_params ); $target_url = $smart_link->target_url; @@ -104,13 +107,13 @@ public function getTargetUrl( $smart_link, $contact ) { $target_url = esc_url_raw( $target_url ); } - if ( false === strpos( $target_url, '?' ) ) { - $target_url .= '?'; - } else { - $target_url .= '&'; + // Only append separator and query string if there are params to forward. + if ( $query_string ) { + $separator = ( false === strpos( $target_url, '?' ) ) ? '?' : '&'; + $target_url = $target_url . $separator . $query_string; } - return $target_url . $query_string; + return $target_url; } /** diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 30bdb6d..64b864b 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -51,116 +51,83 @@ function () { 99 ); -// Hook to add custom dashboard metrics -// add_action( 'fluent_crm/dashboard_stats', 'add_custom_dashboard_metrics' ); - -/** - * Add custom dashboard metrics. - * - * @param array $data The dashboard data. - * - * @return array - */ -function add_custom_dashboard_metrics( $data ) { - // Example: Adding a new metric for total subscribers. - $total_subscribers = \FluentCrm\App\Models\Subscriber::count(); - - $data['total_subscribers_metric'] = [ - 'title' => __( 'Total Subscribers', 'fluent-crm-custom-features' ), - 'count' => $total_subscribers, - 'route' => [ - 'name' => 'subscribers', +// Hook to register a custom REST API endpoint. +add_action( 'rest_api_init', function () { + register_rest_route( 'fluent-crm/v1', '/list-growth', [ + 'methods' => 'GET', + 'callback' => 'customcrm_get_list_growth', + 'permission_callback' => function () { + return current_user_can( 'manage_options' ); + }, + 'args' => [ + 'from' => [ + 'required' => false, + 'validate_callback' => 'customcrm_validate_date_param', + ], + 'to' => [ + 'required' => false, + 'validate_callback' => 'customcrm_validate_date_param', + ], ], - ]; - - // Add more metrics as needed - return $data; -} - -// Hook to register a custom report -add_action( 'fluent_crm/reporting/reports', 'register_custom_report' ); + ] ); +} ); /** - * Register a custom report. + * Validate a date parameter for the REST API. * - * @param array $reports The reports array. + * @param string $value The parameter value. * - * @return array + * @return bool */ -function register_custom_report( $reports ) { - $reports['custom_report'] = [ - 'title' => __( 'Custom Report', 'fluent-crm-custom-features' ), - 'callback' => 'render_custom_report', - ]; - - return $reports; -} - -/** - * Render the custom report. - * - * @return void - */ -function render_custom_report() { - // Logic to render your custom report - $subscribers = \FluentCrm\App\Models\Subscriber::all(); - // Output your report data here - echo '

' . esc_html__( 'Custom Report', 'fluent-crm-custom-features' ) . '

'; - echo '
    '; - foreach ( $subscribers as $subscriber ) { - echo '
  • ' . esc_html( $subscriber->email ) . '
  • '; +function customcrm_validate_date_param( $value ) { + // Allow empty values (defaults will be used). + if ( empty( $value ) ) { + return true; } - echo '
'; -} -// Hook to register a custom REST API endpoint -add_action('rest_api_init', function () { - register_rest_route('fluent-crm/v1', '/list-growth', [ - 'methods' => 'GET', - 'callback' => 'get_list_growth', - 'permission_callback' => '__return_true', - ]); -}); + // Must match YYYY-MM-DD format. + return (bool) preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ); +} /** - * Get List Growth metrics + * Get List Growth metrics. * * @param WP_REST_Request $request The REST request object. + * * @return WP_REST_Response */ -function get_list_growth( WP_REST_Request $request ) { +function customcrm_get_list_growth( WP_REST_Request $request ) { $from = $request->get_param( 'from' ); $to = $request->get_param( 'to' ); - // Convert dates to Carbon instances - $from_date = \Carbon\Carbon::parse( $from ); - $to_date = \Carbon\Carbon::parse( $to ); + // Default to current month if not provided. + $from = ! empty( $from ) ? sanitize_text_field( $from ) : gmdate( 'Y-m-01' ); + $to = ! empty( $to ) ? sanitize_text_field( $to ) : gmdate( 'Y-m-t' ); - // Count new subscribers + // Count new subscribers. $new_subscribers = fluentCrmDb()->table( 'fc_subscribers' ) - ->whereBetween( 'created_at', [ $from_date->format( 'Y-m-d' ), $to_date->format( 'Y-m-d' ) ] ) + ->whereBetween( 'created_at', [ $from, $to ] ) ->where( 'status', 'subscribed' ) ->count(); - // Count unsubscribed + // Count unsubscribed. $unsubscribed = fluentCrmDb()->table( 'fc_subscriber_meta' ) - ->whereBetween( 'created_at', [ $from_date->format( 'Y-m-d' ), $to_date->format( 'Y-m-d' ) ] ) + ->whereBetween( 'created_at', [ $from, $to ] ) ->where( 'key', 'unsubscribe_reason' ) ->count(); - // Calculate net growth + // Calculate net growth. $net_growth = $new_subscribers - $unsubscribed; - return new WP_REST_Response([ + return new WP_REST_Response( [ 'new_subscribers' => $new_subscribers, 'unsubscribed' => $unsubscribed, 'net_growth' => $net_growth, - ], 200); + ], 200 ); } - // Hook to add custom metrics to the dashboard. -add_filter( 'fluent_crm/dashboard_data', 'add_custom_dashboard_metrics_for_list_growth' ); +add_filter( 'fluent_crm/dashboard_data', 'customcrm_add_dashboard_list_growth_metrics' ); /** * Add custom dashboard metrics for list growth. @@ -169,7 +136,7 @@ function get_list_growth( WP_REST_Request $request ) { * * @return array */ -function add_custom_dashboard_metrics_for_list_growth( $data ) { +function customcrm_add_dashboard_list_growth_metrics( $data ) { // Get the date range from the request or set default values. // phpcs:disable WordPress.Security.NonceVerification.Recommended $from = isset( $_GET['from'] ) ? sanitize_text_field( wp_unslash( $_GET['from'] ) ) : gmdate( 'Y-m-01' ); From c797a5ea45a842ac77aa4ae14797821080ba94e0 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 19 Feb 2026 11:03:09 -0500 Subject: [PATCH 05/14] feat: add automation conditions and Drip merge tag migration - Add AutomationConditions class that registers event tracking and automation completion condition groups in FluentCRM's funnel condition block (fixes MAR-8 and MAR-9) - Add FixDripMergeTags migration to convert Drip merge tags to FluentCRM equivalents in already-imported campaigns and funnel sequences (fixes MAR-12) --- classes/Conditions/AutomationConditions.php | 166 ++++++++++++++++++ classes/Migrations/FixDripMergeTags.php | 184 ++++++++++++++++++++ fluent-crm-custom-features.php | 3 + 3 files changed, 353 insertions(+) create mode 100644 classes/Conditions/AutomationConditions.php create mode 100644 classes/Migrations/FixDripMergeTags.php diff --git a/classes/Conditions/AutomationConditions.php b/classes/Conditions/AutomationConditions.php new file mode 100644 index 0000000..6aeca4b --- /dev/null +++ b/classes/Conditions/AutomationConditions.php @@ -0,0 +1,166 @@ + $groups Existing condition groups. + * @param mixed $funnel The current funnel. + * + * @return array + */ + public function addConditionGroups( array $groups, $funnel ): array { + // Add Event Tracking group (evaluation handled by FunnelConditionHelper::assessEventTrackingConditions). + if ( Helper::isExperimentalEnabled( 'event_tracking' ) ) { + $groups['event_tracking'] = [ + 'label' => __( 'Event Tracking', 'fluent-crm-custom-features' ), + 'value' => 'event_tracking', + 'children' => $this->getEventTrackingChildren(), + ]; + } + + // Add Automation Completion group. + $groups['automations'] = [ + 'label' => __( 'Automations', 'fluent-crm-custom-features' ), + 'value' => 'automations', + 'children' => $this->getAutomationChildren(), + ]; + + return $groups; + } + + /** + * Get event tracking condition options. + * + * Provides a list of tracked event keys that can be used as conditions. + * + * @return array> + */ + private function getEventTrackingChildren(): array { + $events = fluentCrmDb()->table( 'fc_event_tracking' ) + ->select( 'event_key', 'title' ) + ->groupBy( 'event_key' ) + ->get(); + + $children = []; + foreach ( $events as $event ) { + $children[] = [ + 'label' => $event->title ?: $event->event_key, + 'value' => $event->event_key, + 'type' => 'selections', + 'options' => [ + 'yes' => __( 'Yes - Has performed', 'fluent-crm-custom-features' ), + 'no' => __( 'No - Has not performed', 'fluent-crm-custom-features' ), + ], + 'is_multiple' => false, + 'is_singular_value' => true, + ]; + } + + return $children; + } + + /** + * Get automation completion condition options. + * + * Provides a list of funnels that can be checked for completion. + * + * @return array> + */ + private function getAutomationChildren(): array { + $funnels = fluentCrmDb()->table( 'fc_funnels' ) + ->select( 'id', 'title' ) + ->orderBy( 'title', 'ASC' ) + ->get(); + + $children = []; + foreach ( $funnels as $funnel_item ) { + $children[] = [ + 'label' => $funnel_item->title, + 'value' => 'funnel_completed_' . $funnel_item->id, + 'type' => 'selections', + 'options' => [ + 'yes' => __( 'Yes - Has completed', 'fluent-crm-custom-features' ), + 'no' => __( 'No - Has not completed', 'fluent-crm-custom-features' ), + ], + 'is_multiple' => false, + 'is_singular_value' => true, + ]; + } + + return $children; + } + + /** + * Assess automation completion conditions. + * + * @param bool $result Current result. + * @param array $conditions Condition rules to evaluate. + * @param object $subscriber The subscriber being evaluated. + * @param object $sequence The current funnel sequence. + * @param int $funnelSubscriberId The funnel subscriber ID. + * + * @return bool + */ + public function assessAutomationConditions( $result, $conditions, $subscriber, $sequence, $funnelSubscriberId ): bool { + foreach ( $conditions as $condition ) { + $prop = $condition['data_key']; + $operator = $condition['operator'] ?? '='; + $value = $condition['data_value'] ?? 'yes'; + + // Extract funnel ID from the property name (funnel_completed_{id}). + if ( strpos( $prop, 'funnel_completed_' ) !== 0 ) { + continue; + } + + $funnel_id = (int) str_replace( 'funnel_completed_', '', $prop ); + if ( ! $funnel_id ) { + continue; + } + + $has_completed = FunnelSubscriber::where( 'subscriber_id', $subscriber->id ) + ->where( 'funnel_id', $funnel_id ) + ->where( 'status', 'completed' ) + ->exists(); + + $expects_completed = ( $value === 'yes' ); + + if ( $operator === '=' || $operator === 'in' ) { + if ( $has_completed !== $expects_completed ) { + return false; + } + } elseif ( $operator === '!=' || $operator === 'not_in' ) { + if ( $has_completed === $expects_completed ) { + return false; + } + } + } + + return true; + } +} diff --git a/classes/Migrations/FixDripMergeTags.php b/classes/Migrations/FixDripMergeTags.php new file mode 100644 index 0000000..b13af0c --- /dev/null +++ b/classes/Migrations/FixDripMergeTags.php @@ -0,0 +1,184 @@ + + */ + private static array $replacements = [ + // Footer links (full HTML versions). + '{{ manage_subscriptions_link }}' => '{{crm.manage_subscription_html|Manage Preferences}}', + '{{ unsubscribe_link }}' => '{{crm.unsubscribe_html|Unsubscribe}}', + + // Footer links (URL-only versions). + '{{ manage_subscriptions_url }}' => '##crm.manage_subscription_url##', + '{{ unsubscribe_url }}' => '##crm.unsubscribe_url##', + + // Postal address and account. + '{{ inline_postal_address }}' => '{{crm.business_address}}', + '{{ account.name }}' => '{{crm.business_name}}', + + // Subscriber fields. + '{{ subscriber.first_name }}' => '{{contact.first_name}}', + '{{ subscriber.last_name }}' => '{{contact.last_name}}', + '{{ subscriber.email }}' => '{{contact.email}}', + ]; + + /** + * Regex-based replacements for patterns with variable content. + * + * @var array + */ + private static array $regex_replacements = [ + '/\{\{\s*manage_subscriptions_link\s*\}\}/i' => '{{crm.manage_subscription_html|Manage Preferences}}', + '/\{\{\s*unsubscribe_link\s*\}\}/i' => '{{crm.unsubscribe_html|Unsubscribe}}', + '/\{\{\s*manage_subscriptions_url\s*\}\}/i' => '##crm.manage_subscription_url##', + '/\{\{\s*unsubscribe_url\s*\}\}/i' => '##crm.unsubscribe_url##', + '/\{\{\s*inline_postal_address\s*\}\}/i' => '{{crm.business_address}}', + '/\{\{\s*account\.name\s*\}\}/i' => '{{crm.business_name}}', + '/\{\{\s*subscriber\.first_name\s*\}\}/i' => '{{contact.first_name}}', + '/\{\{\s*subscriber\.last_name\s*\}\}/i' => '{{contact.last_name}}', + '/\{\{\s*subscriber\.email\s*\}\}/i' => '{{contact.email}}', + '/\{\{\s*subscriber\.custom_fields\.(\w+)\s*\}\}/i' => '{{contact.custom.$1}}', + '/\{\{\s*subscriber\.(\w+)\s*\}\}/i' => '{{contact.$1}}', + ]; + + /** + * Run the migration. + * + * @param bool $dry_run If true, only report what would change without modifying data. + * + * @return array{updated: int, skipped: int, errors: int} + */ + public static function run( bool $dry_run = false ): array { + $db = fluentCrmDb(); + + // Find all campaigns with Drip merge tags in email_body. + $campaigns = $db->table( 'fc_campaigns' ) + ->where( 'email_body', 'LIKE', '%{{ %' ) + ->where(function ( $q ) { + $q->where( 'email_body', 'LIKE', '%subscriber.%' ) + ->orWhere( 'email_body', 'LIKE', '%unsubscribe_link%' ) + ->orWhere( 'email_body', 'LIKE', '%manage_subscriptions%' ) + ->orWhere( 'email_body', 'LIKE', '%inline_postal_address%' ) + ->orWhere( 'email_body', 'LIKE', '%account.name%' ); + } ) + ->get(); + + $stats = [ + 'updated' => 0, + 'skipped' => 0, + 'errors' => 0, + ]; + + foreach ( $campaigns as $campaign ) { + $original = $campaign->email_body; + $updated = self::convertMergeTags( $original ); + + if ( $updated === $original ) { + ++$stats['skipped']; + continue; + } + + if ( $dry_run ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( sprintf( + '[FixDripMergeTags] DRY RUN — Would update campaign #%d (%s)', + $campaign->id, + $campaign->title ?? 'untitled' + ) ); + ++$stats['updated']; + continue; + } + + $result = $db->table( 'fc_campaigns' ) + ->where( 'id', $campaign->id ) + ->update( [ 'email_body' => $updated ] ); + + if ( $result !== false ) { + ++$stats['updated']; + } else { + ++$stats['errors']; + } + } + + // Also fix campaign_emails (individual sent emails) if they exist. + $emails = $db->table( 'fc_campaign_emails' ) + ->where( 'email_body', 'LIKE', '%{{ %' ) + ->where(function ( $q ) { + $q->where( 'email_body', 'LIKE', '%subscriber.%' ) + ->orWhere( 'email_body', 'LIKE', '%unsubscribe_link%' ) + ->orWhere( 'email_body', 'LIKE', '%manage_subscriptions%' ); + } ) + ->get(); + + foreach ( $emails as $email ) { + $original = $email->email_body; + $updated = self::convertMergeTags( $original ); + + if ( $updated === $original ) { + continue; + } + + if ( ! $dry_run ) { + $db->table( 'fc_campaign_emails' ) + ->where( 'id', $email->id ) + ->update( [ 'email_body' => $updated ] ); + } + } + + // Also fix funnel sequence settings (automation email bodies). + $sequences = $db->table( 'fc_funnel_sequences' ) + ->where( 'action_name', 'send_custom_email' ) + ->get(); + + foreach ( $sequences as $seq ) { + $settings = maybe_unserialize( $seq->settings ); + if ( ! is_array( $settings ) || empty( $settings['email_body'] ) ) { + continue; + } + + $original = $settings['email_body']; + $updated = self::convertMergeTags( $original ); + + if ( $updated === $original ) { + continue; + } + + if ( ! $dry_run ) { + $settings['email_body'] = $updated; + $db->table( 'fc_funnel_sequences' ) + ->where( 'id', $seq->id ) + ->update( [ 'settings' => maybe_serialize( $settings ) ] ); + } + } + + return $stats; + } + + /** + * Convert Drip merge tags to FluentCRM merge tags. + * + * @param string $text Email content with Drip merge tags. + * + * @return string Content with FluentCRM merge tags. + */ + public static function convertMergeTags( string $text ): string { + foreach ( self::$regex_replacements as $pattern => $replacement ) { + $text = preg_replace( $pattern, $replacement, $text ); + } + + return $text; + } +} diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 64b864b..bb8c605 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -39,6 +39,9 @@ function () { // Enable our custom webhook handler. ( new \CustomCRM\Webhooks() ); + // Register custom automation conditions (event tracking + automation completion). + ( new \CustomCRM\Conditions\AutomationConditions() )->register(); + // Remove the default smart link handler. remove_all_actions( 'fluentcrm_smartlink_clicked' ); remove_all_actions( 'fluentcrm_smartlink_clicked_direct' ); From 64edb26285b2c4a7d86e0d6dd6aee266aa750ca8 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 19 Feb 2026 11:25:10 -0500 Subject: [PATCH 06/14] fix: critical bugs in automation conditions and merge tag migration - AutomationConditions: return $result instead of true to preserve prior filter results in condition chain - FixDripMergeTags: guard against preg_replace returning null which would wipe email content - FixDripMergeTags: add LIKE guard to funnel sequences query to avoid full table scan --- classes/Conditions/AutomationConditions.php | 2 +- classes/Migrations/FixDripMergeTags.php | 6 +++++- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/classes/Conditions/AutomationConditions.php b/classes/Conditions/AutomationConditions.php index 6aeca4b..5dd8e84 100644 --- a/classes/Conditions/AutomationConditions.php +++ b/classes/Conditions/AutomationConditions.php @@ -161,6 +161,6 @@ public function assessAutomationConditions( $result, $conditions, $subscriber, $ } } - return true; + return $result; } } diff --git a/classes/Migrations/FixDripMergeTags.php b/classes/Migrations/FixDripMergeTags.php index b13af0c..613291c 100644 --- a/classes/Migrations/FixDripMergeTags.php +++ b/classes/Migrations/FixDripMergeTags.php @@ -141,6 +141,7 @@ public static function run( bool $dry_run = false ): array { // Also fix funnel sequence settings (automation email bodies). $sequences = $db->table( 'fc_funnel_sequences' ) ->where( 'action_name', 'send_custom_email' ) + ->where( 'settings', 'LIKE', '%{{ %' ) ->get(); foreach ( $sequences as $seq ) { @@ -176,7 +177,10 @@ public static function run( bool $dry_run = false ): array { */ public static function convertMergeTags( string $text ): string { foreach ( self::$regex_replacements as $pattern => $replacement ) { - $text = preg_replace( $pattern, $replacement, $text ); + $result = preg_replace( $pattern, $replacement, $text ); + if ( $result !== null ) { + $text = $result; + } } return $text; From b9b03d902beeee1a96a7cf4f6b23e0bb2fefad76 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 19 Feb 2026 13:03:00 -0500 Subject: [PATCH 07/14] fix: fail-safe for unknown operators, complete campaign_emails migration - AutomationConditions: unknown operators now fail safe instead of silently passing - FixDripMergeTags: campaign_emails query now checks for inline_postal_address and account.name tags (matching campaigns query) - FixDripMergeTags: campaign_emails loop now tracks stats (updated, skipped, errors) for complete reporting --- classes/Conditions/AutomationConditions.php | 3 +++ classes/Migrations/FixDripMergeTags.php | 22 ++++++++++++++++----- 2 files changed, 20 insertions(+), 5 deletions(-) diff --git a/classes/Conditions/AutomationConditions.php b/classes/Conditions/AutomationConditions.php index 5dd8e84..69b71c7 100644 --- a/classes/Conditions/AutomationConditions.php +++ b/classes/Conditions/AutomationConditions.php @@ -158,6 +158,9 @@ public function assessAutomationConditions( $result, $conditions, $subscriber, $ if ( $has_completed === $expects_completed ) { return false; } + } else { + // Unknown operator — fail safe. + return false; } } diff --git a/classes/Migrations/FixDripMergeTags.php b/classes/Migrations/FixDripMergeTags.php index 613291c..f840835 100644 --- a/classes/Migrations/FixDripMergeTags.php +++ b/classes/Migrations/FixDripMergeTags.php @@ -119,7 +119,9 @@ public static function run( bool $dry_run = false ): array { ->where(function ( $q ) { $q->where( 'email_body', 'LIKE', '%subscriber.%' ) ->orWhere( 'email_body', 'LIKE', '%unsubscribe_link%' ) - ->orWhere( 'email_body', 'LIKE', '%manage_subscriptions%' ); + ->orWhere( 'email_body', 'LIKE', '%manage_subscriptions%' ) + ->orWhere( 'email_body', 'LIKE', '%inline_postal_address%' ) + ->orWhere( 'email_body', 'LIKE', '%account.name%' ); } ) ->get(); @@ -128,13 +130,23 @@ public static function run( bool $dry_run = false ): array { $updated = self::convertMergeTags( $original ); if ( $updated === $original ) { + ++$stats['skipped']; continue; } - if ( ! $dry_run ) { - $db->table( 'fc_campaign_emails' ) - ->where( 'id', $email->id ) - ->update( [ 'email_body' => $updated ] ); + if ( $dry_run ) { + ++$stats['updated']; + continue; + } + + $result = $db->table( 'fc_campaign_emails' ) + ->where( 'id', $email->id ) + ->update( [ 'email_body' => $updated ] ); + + if ( $result !== false ) { + ++$stats['updated']; + } else { + ++$stats['errors']; } } From 5df99ce7e6cd7efa12c554ba3baecfd7a9086446 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 19 Feb 2026 17:05:35 -0500 Subject: [PATCH 08/14] feat: track EDD license activations as FluentCRM events Hook into edd_sl_activate_license to record an event in fc_event_tracking so funnel conditions can check "has performed: Activated license key". --- classes/EddLicenseActivationTracker.php | 54 +++++++++++++++++++++++++ fluent-crm-custom-features.php | 3 ++ 2 files changed, 57 insertions(+) create mode 100644 classes/EddLicenseActivationTracker.php diff --git a/classes/EddLicenseActivationTracker.php b/classes/EddLicenseActivationTracker.php new file mode 100644 index 0000000..e534703 --- /dev/null +++ b/classes/EddLicenseActivationTracker.php @@ -0,0 +1,54 @@ +get_license( $license_id ); + + if ( ! $license || ! $license->customer_id ) { + return; + } + + $customer = new \EDD_Customer( $license->customer_id ); + + if ( ! $customer || ! $customer->email ) { + return; + } + + $download = get_post( $download_id ); + $product_name = $download ? $download->post_title : ''; + + FluentCrmApi( 'tracker' )->track( [ + 'email' => $customer->email, + 'provider' => 'edd', + 'event_key' => 'license_activated', + 'title' => 'Activated license key', + 'value' => $product_name, + ] ); + } +} diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index bb8c605..937aa47 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -42,6 +42,9 @@ function () { // Register custom automation conditions (event tracking + automation completion). ( new \CustomCRM\Conditions\AutomationConditions() )->register(); + // Track EDD license activations as FluentCRM events. + ( new \CustomCRM\EddLicenseActivationTracker() )->register(); + // Remove the default smart link handler. remove_all_actions( 'fluentcrm_smartlink_clicked' ); remove_all_actions( 'fluentcrm_smartlink_clicked_direct' ); From d0954b75ec0e3448671ad0252108204723a1e28f Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 19 Feb 2026 17:05:41 -0500 Subject: [PATCH 09/14] feat: add EDD license status condition for funnel automation New "EDD Licenses" condition group lets funnels check if a contact has an active license for a specific product via the edd_licenses table. --- classes/Conditions/AutomationConditions.php | 126 ++++++++++++++++++++ 1 file changed, 126 insertions(+) diff --git a/classes/Conditions/AutomationConditions.php b/classes/Conditions/AutomationConditions.php index 69b71c7..c55e23f 100644 --- a/classes/Conditions/AutomationConditions.php +++ b/classes/Conditions/AutomationConditions.php @@ -12,6 +12,7 @@ * Adds: * - Event Tracking conditions (check if contact has performed a specific event) * - Automation Completion conditions (check if contact has completed a specific funnel) + * - EDD License conditions (check if contact has an active license for a product) */ class AutomationConditions { @@ -24,6 +25,9 @@ public function register(): void { // Handle evaluation of our custom "automation completed" condition. add_filter( 'fluentcrm_automation_conditions_assess_automations', [ $this, 'assessAutomationConditions' ], 10, 5 ); + + // Handle evaluation of our custom "EDD license" condition. + add_filter( 'fluentcrm_automation_conditions_assess_edd_licenses', [ $this, 'assessEddLicenseConditions' ], 10, 5 ); } /** @@ -51,6 +55,15 @@ public function addConditionGroups( array $groups, $funnel ): array { 'children' => $this->getAutomationChildren(), ]; + // Add EDD License group. + if ( function_exists( 'edd_software_licensing' ) ) { + $groups['edd_licenses'] = [ + 'label' => __( 'EDD Licenses', 'fluent-crm-custom-features' ), + 'value' => 'edd_licenses', + 'children' => $this->getEddLicenseChildren(), + ]; + } + return $groups; } @@ -116,6 +129,119 @@ private function getAutomationChildren(): array { return $children; } + /** + * Get EDD license condition options. + * + * Lists all EDD downloads that have at least one license, so the condition + * UI shows "Has active license for [Product Name]" yes/no. + * + * @return array> + */ + private function getEddLicenseChildren(): array { + $products = fluentCrmDb()->table( 'edd_licenses' ) + ->select( 'download_id' ) + ->groupBy( 'download_id' ) + ->get(); + + $children = []; + foreach ( $products as $product ) { + $download = get_post( $product->download_id ); + if ( ! $download ) { + continue; + } + + $children[] = [ + 'label' => $download->post_title, + 'value' => 'edd_license_' . $product->download_id, + 'type' => 'selections', + 'options' => [ + 'yes' => __( 'Yes - Has active license', 'fluent-crm-custom-features' ), + 'no' => __( 'No - Does not have active license', 'fluent-crm-custom-features' ), + ], + 'is_multiple' => false, + 'is_singular_value' => true, + ]; + } + + return $children; + } + + /** + * Assess EDD license conditions. + * + * Checks if the subscriber has an active (or inactive) license for a specific + * EDD product by querying the edd_licenses table via customer_id. + * + * @param bool $result Current result. + * @param array $conditions Condition rules to evaluate. + * @param object $subscriber The subscriber being evaluated. + * @param object $sequence The current funnel sequence. + * @param int $funnelSubscriberId The funnel subscriber ID. + * + * @return bool + */ + public function assessEddLicenseConditions( $result, $conditions, $subscriber, $sequence, $funnelSubscriberId ): bool { + if ( ! function_exists( 'edd_software_licensing' ) ) { + return false; + } + + // Resolve the EDD customer from the subscriber's email or user_id. + $customer = null; + if ( $subscriber->user_id ) { + $customer = new \EDD_Customer( $subscriber->user_id, true ); + } + if ( ( ! $customer || ! $customer->id ) && $subscriber->email ) { + $customer = new \EDD_Customer( $subscriber->email ); + } + if ( ! $customer || ! $customer->id ) { + // No EDD customer — all "has active license" conditions fail. + foreach ( $conditions as $condition ) { + $value = $condition['data_value'] ?? 'yes'; + if ( $value === 'yes' ) { + return false; + } + } + return $result; + } + + foreach ( $conditions as $condition ) { + $prop = $condition['data_key']; + $operator = $condition['operator'] ?? '='; + $value = $condition['data_value'] ?? 'yes'; + + if ( strpos( $prop, 'edd_license_' ) !== 0 ) { + continue; + } + + $download_id = (int) str_replace( 'edd_license_', '', $prop ); + if ( ! $download_id ) { + continue; + } + + $has_active_license = fluentCrmDb()->table( 'edd_licenses' ) + ->where( 'customer_id', $customer->id ) + ->where( 'download_id', $download_id ) + ->whereIn( 'status', [ 'active', 'inactive' ] ) + ->exists(); + + $expects_active = ( $value === 'yes' ); + + if ( $operator === '=' || $operator === 'in' ) { + if ( $has_active_license !== $expects_active ) { + return false; + } + } elseif ( $operator === '!=' || $operator === 'not_in' ) { + if ( $has_active_license === $expects_active ) { + return false; + } + } else { + return false; + } + } + + return $result; + } + /** * Assess automation completion conditions. * From 086fa1a506b16dd869fe90815ecac75acc042115 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 19 Feb 2026 17:27:43 -0500 Subject: [PATCH 10/14] feat: replace composer autoloader with simple PSR-4, add GH Actions release No runtime composer dependencies exist, so replace vendor/autoload.php with a lightweight spl_autoload_register. Add GitHub Actions workflow that zips and creates a release on tag push. --- .github/workflows/release.yml | 34 ++++++++++++++++++++++++++++++++++ fluent-crm-custom-features.php | 14 ++++++++++++-- package.json | 4 +--- 3 files changed, 47 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..d635786 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,34 @@ +name: Build Release + +on: + push: + tags: + - '*' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Get plugin version + id: version + run: echo "version=${GITHUB_REF#refs/tags/}" >> "$GITHUB_OUTPUT" + + - name: Build zip + run: | + plugin_name="fluent-crm-custom-features" + mkdir "$plugin_name" + cp -r classes "$plugin_name/" + cp *.php "$plugin_name/" + zip -r "${plugin_name}.zip" "$plugin_name" + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + files: fluent-crm-custom-features.zip + generate_release_notes: true diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 937aa47..4590ab6 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -18,8 +18,18 @@ * @copyright Copyright (c) 2024, Code Atlantic LLC. */ -// Register autoloader. -require_once __DIR__ . '/vendor/autoload.php'; +// PSR-4 autoloader for CustomCRM namespace. +spl_autoload_register( function ( $class ) { + $prefix = 'CustomCRM\\'; + if ( strncmp( $prefix, $class, strlen( $prefix ) ) !== 0 ) { + return; + } + $relative_class = substr( $class, strlen( $prefix ) ); + $file = __DIR__ . '/classes/' . str_replace( '\\', '/', $relative_class ) . '.php'; + if ( file_exists( $file ) ) { + require $file; + } +} ); add_action( 'init', diff --git a/package.json b/package.json index 964d7de..de6006e 100644 --- a/package.json +++ b/package.json @@ -17,9 +17,7 @@ }, "files": [ "classes/**/*", - "vendor-prefixed/**/*", - "*.php", - ".phpcs.xml.dist" + "*.php" ], "scripts": { "release": "node bin/build-release.js" From 0f299bfc7f37c49ae9ceac958cbcc36493f8bb97 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Wed, 4 Mar 2026 17:55:20 -0500 Subject: [PATCH 11/14] feat(email): add Custom CSS editor for FluentCRM email templates Allows injecting arbitrary CSS into all outgoing FluentCRM emails via a new admin page (FluentCRM > Custom CSS) with a CodeMirror editor. - New CustomEmailCSS class handles admin page, save, and email injection - CSS injected before after all template defaults for natural priority - Sanitizes input against XSS vectors (expression(), @import, javascript:, etc.) - Accessible: labeled textarea, role="alert" on notices, translatable strings - Uses WordPress CodeMirror (wp_enqueue_code_editor) in CSS mode --- classes/Integrations/CustomEmailCSS.php | 229 ++++++++++++++++++++++++ fluent-crm-custom-features.php | 5 +- 2 files changed, 233 insertions(+), 1 deletion(-) create mode 100644 classes/Integrations/CustomEmailCSS.php diff --git a/classes/Integrations/CustomEmailCSS.php b/classes/Integrations/CustomEmailCSS.php new file mode 100644 index 0000000..2a3cfcc --- /dev/null +++ b/classes/Integrations/CustomEmailCSS.php @@ -0,0 +1,229 @@ +. + * + * @param string $html The rendered email HTML. + * @param string $email_body The email body content. + * @param array $template_config Template configuration. + * + * @return string + */ + public function inject_css( string $html, $email_body = '', $template_config = [] ): string { + $css = $this->get_css(); + + if ( empty( $css ) ) { + return $html; + } + + return str_replace( + '', + '' . "\n" . '', + $html + ); + } + + /** + * Add submenu page under FluentCRM. + */ + public function add_menu_page(): void { + add_submenu_page( + 'fluentcrm-admin', + esc_html__( 'Custom Email CSS', 'fluent-crm-custom-features' ), + esc_html__( 'Custom CSS', 'fluent-crm-custom-features' ), + 'manage_options', + self::PAGE_SLUG, + [ $this, 'render_page' ] + ); + } + + /** + * Enqueue CodeMirror assets on our admin page only. + * + * @param string $hook_suffix The current admin page hook suffix. + */ + public function enqueue_scripts( string $hook_suffix ): void { + if ( 'fluentcrm_page_' . self::PAGE_SLUG !== $hook_suffix ) { + return; + } + + $settings = wp_enqueue_code_editor( [ 'type' => 'text/css' ] ); + + if ( false === $settings ) { + return; + } + + wp_add_inline_script( + 'code-editor', + sprintf( + 'jQuery( function() { wp.codeEditor.initialize( "fluentcrm-custom-css", %s ); } );', + wp_json_encode( $settings ) + ) + ); + } + + /** + * Handle the form save. + */ + public function handle_save(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( + esc_html__( 'You do not have permission to access this page.', 'fluent-crm-custom-features' ), + 403 + ); + } + + check_admin_referer( 'fluentcrm_custom_css_save' ); + + // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- sanitized via sanitize_css() below. + $css = isset( $_POST['custom_css'] ) ? wp_unslash( $_POST['custom_css'] ) : ''; + $css = $this->sanitize_css( $css ); + + fluentcrm_update_option( self::OPTION_KEY, $css ); + + wp_safe_redirect( add_query_arg( + [ + 'page' => self::PAGE_SLUG, + 'updated' => '1', + ], + admin_url( 'admin.php' ) + ) ); + exit; + } + + /** + * Render the admin page. + */ + public function render_page(): void { + if ( ! current_user_can( 'manage_options' ) ) { + wp_die( + esc_html__( 'You do not have permission to access this page.', 'fluent-crm-custom-features' ), + 403 + ); + } + + $css = $this->get_css(); + + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + $updated = isset( $_GET['updated'] ) && '1' === $_GET['updated']; + + ?> +
+

+ + + + + +

+ !important markup */ + esc_html__( 'Add custom CSS that will be injected into all FluentCRM email templates. These styles are added after all default template CSS, so they take priority without needing %s.', 'fluent-crm-custom-features' ), + '!important' + ); + ?> +

+ +
+ + + + + + + +
+
+ register(); }, 99 ); -// Hook to register a custom REST API endpoint. +// Hook to register custom REST API endpoints. add_action( 'rest_api_init', function () { register_rest_route( 'fluent-crm/v1', '/list-growth', [ 'methods' => 'GET', From 7618b13a6a847e6653f06e6ce815214d69c1f070 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Wed, 4 Mar 2026 18:06:06 -0500 Subject: [PATCH 12/14] fix(email-css): auto-append !important and fix CodeMirror loading - Auto-append !important to every CSS declaration at injection time so custom styles override inline styles and template !important rules - Strip existing !important before re-adding to prevent doubling - Fix CodeMirror not loading: check $_GET['page'] instead of hook suffix which varies depending on how FluentCRM registers its parent menu - Update admin page description to reflect !important behavior --- classes/Integrations/CustomEmailCSS.php | 41 +++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/classes/Integrations/CustomEmailCSS.php b/classes/Integrations/CustomEmailCSS.php index 2a3cfcc..fed06c7 100644 --- a/classes/Integrations/CustomEmailCSS.php +++ b/classes/Integrations/CustomEmailCSS.php @@ -63,6 +63,8 @@ public function inject_css( string $html, $email_body = '', $template_config = [ return $html; } + $css = $this->add_important( $css ); + return str_replace( '', '' . "\n" . '', @@ -90,7 +92,8 @@ public function add_menu_page(): void { * @param string $hook_suffix The current admin page hook suffix. */ public function enqueue_scripts( string $hook_suffix ): void { - if ( 'fluentcrm_page_' . self::PAGE_SLUG !== $hook_suffix ) { + // phpcs:ignore WordPress.Security.NonceVerification.Recommended + if ( ! isset( $_GET['page'] ) || self::PAGE_SLUG !== $_GET['page'] ) { return; } @@ -168,7 +171,7 @@ public function render_page(): void { !important markup */ - esc_html__( 'Add custom CSS that will be injected into all FluentCRM email templates. These styles are added after all default template CSS, so they take priority without needing %s.', 'fluent-crm-custom-features' ), + esc_html__( 'Add custom CSS that will be injected into all FluentCRM email templates. %s is automatically added to every declaration so your styles override inline styles and template defaults.', 'fluent-crm-custom-features' ), '!important' ); ?> @@ -203,6 +206,40 @@ private function get_css(): string { return (string) fluentcrm_get_option( self::OPTION_KEY, '' ); } + /** + * Append !important to every CSS declaration that doesn't already have it. + * + * This ensures custom styles override both inline styles and template + * defaults that use !important. Users write clean CSS; the flag is + * added automatically at injection time. + * + * @param string $css The CSS to process. + * + * @return string CSS with !important on every declaration. + */ + private function add_important( string $css ): string { + // Strip any existing !important to avoid doubling. + $css = preg_replace( '/\s*!important\b/', '', $css ); + + // Match: "property: value;" — insert !important before the semicolon. + // Uses a negative lookbehind for { and } to skip selectors/at-rules. + // The pattern matches "anything : anything ;" which covers all declarations. + $css = preg_replace( + '/(:[^;{}]+?)\s*(;)/', + '$1 !important$2', + $css + ); + + // Handle the last declaration before } which may omit the semicolon. + $css = preg_replace( + '/(:[^;{}]+?)\s*(})/', + '$1 !important;$2', + $css + ); + + return $css; + } + /** * Sanitize CSS input by stripping tags and dangerous CSS constructs. * From f5d74de77c3c2455279528228cbf3ad7d76a56a4 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 5 Mar 2026 14:25:41 -0500 Subject: [PATCH 13/14] feat(conditions): add granular EDD license status options The "Has active license" condition was a binary yes/no that matched both active and inactive statuses. Automations needed finer control to distinguish between specific license states. - Replace yes/no with multi-select: Valid, Active, Inactive, Expired, Disabled - "Valid" maps to active+inactive (non-expired, non-revoked) - Add mapLicenseOptionToStatuses() for clean status resolution - Backward-compatible: old yes/no values auto-convert to new format - Guard against empty status arrays in whereIn queries --- classes/Conditions/AutomationConditions.php | 79 ++++++++++++++++----- 1 file changed, 60 insertions(+), 19 deletions(-) diff --git a/classes/Conditions/AutomationConditions.php b/classes/Conditions/AutomationConditions.php index c55e23f..6be804c 100644 --- a/classes/Conditions/AutomationConditions.php +++ b/classes/Conditions/AutomationConditions.php @@ -155,11 +155,14 @@ private function getEddLicenseChildren(): array { 'value' => 'edd_license_' . $product->download_id, 'type' => 'selections', 'options' => [ - 'yes' => __( 'Yes - Has active license', 'fluent-crm-custom-features' ), - 'no' => __( 'No - Does not have active license', 'fluent-crm-custom-features' ), + 'valid' => __( 'Valid license (active or inactive)', 'fluent-crm-custom-features' ), + 'active' => __( 'Active (activated on a site)', 'fluent-crm-custom-features' ), + 'inactive' => __( 'Inactive (not activated)', 'fluent-crm-custom-features' ), + 'expired' => __( 'Expired', 'fluent-crm-custom-features' ), + 'disabled' => __( 'Disabled', 'fluent-crm-custom-features' ), ], - 'is_multiple' => false, - 'is_singular_value' => true, + 'is_multiple' => true, + 'is_singular_value' => false, ]; } @@ -194,20 +197,14 @@ public function assessEddLicenseConditions( $result, $conditions, $subscriber, $ $customer = new \EDD_Customer( $subscriber->email ); } if ( ! $customer || ! $customer->id ) { - // No EDD customer — all "has active license" conditions fail. - foreach ( $conditions as $condition ) { - $value = $condition['data_value'] ?? 'yes'; - if ( $value === 'yes' ) { - return false; - } - } - return $result; + // No EDD customer — no license can match. + return false; } foreach ( $conditions as $condition ) { $prop = $condition['data_key']; $operator = $condition['operator'] ?? '='; - $value = $condition['data_value'] ?? 'yes'; + $value = $condition['data_value'] ?? []; if ( strpos( $prop, 'edd_license_' ) !== 0 ) { continue; @@ -218,20 +215,38 @@ public function assessEddLicenseConditions( $result, $conditions, $subscriber, $ continue; } - $has_active_license = fluentCrmDb()->table( 'edd_licenses' ) + // Backward compatibility: convert old yes/no values. + if ( $value === 'yes' ) { + $value = [ 'valid' ]; + } elseif ( $value === 'no' ) { + $value = [ 'valid' ]; + $operator = ( $operator === '=' || $operator === 'in' ) ? '!=' : '='; + } + + // Normalize value to an array of selected options. + if ( ! is_array( $value ) ) { + $value = [ $value ]; + } + + // Map selected options to EDD license statuses. + $statuses = $this->mapLicenseOptionToStatuses( $value ); + + if ( empty( $statuses ) ) { + return false; + } + + $has_matching_license = fluentCrmDb()->table( 'edd_licenses' ) ->where( 'customer_id', $customer->id ) ->where( 'download_id', $download_id ) - ->whereIn( 'status', [ 'active', 'inactive' ] ) + ->whereIn( 'status', $statuses ) ->exists(); - $expects_active = ( $value === 'yes' ); - if ( $operator === '=' || $operator === 'in' ) { - if ( $has_active_license !== $expects_active ) { + if ( ! $has_matching_license ) { return false; } } elseif ( $operator === '!=' || $operator === 'not_in' ) { - if ( $has_active_license === $expects_active ) { + if ( $has_matching_license ) { return false; } } else { @@ -242,6 +257,32 @@ public function assessEddLicenseConditions( $result, $conditions, $subscriber, $ return $result; } + /** + * Map UI option values to EDD license status strings. + * + * @param array $options Selected option values (e.g. ['valid', 'expired']). + * + * @return array EDD license statuses to query. + */ + private function mapLicenseOptionToStatuses( array $options ): array { + $status_map = [ + 'valid' => [ 'active', 'inactive' ], + 'active' => [ 'active' ], + 'inactive' => [ 'inactive' ], + 'expired' => [ 'expired' ], + 'disabled' => [ 'disabled' ], + ]; + + $statuses = []; + foreach ( $options as $option ) { + if ( isset( $status_map[ $option ] ) ) { + $statuses = array_merge( $statuses, $status_map[ $option ] ); + } + } + + return array_unique( $statuses ); + } + /** * Assess automation completion conditions. * From ae91ef2652d513b9228e2cbcd81695c8ad801bab Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 5 Mar 2026 14:25:53 -0500 Subject: [PATCH 14/14] fix: address CodeRabbit review findings across plugin Multiple potential issues found during code review needed correcting to prevent edge-case bugs and improve security hardening. - SmartLinkHandler: use map_deep() for nested array sanitization - EddLicenseActivationTracker: check $customer->id not truthy $customer - UpdateContactPropertyAction: filter empty strings after trim - FixDripMergeTags: track stats for funnel sequence updates/skips/errors - CustomEmailCSS: fix wp_die() signature, null-coalesce preg_replace, prevent style tag breakout - Main plugin: validate dates with checkdate(), include full day in whereBetween ranges --- classes/Actions/UpdateContactPropertyAction.php | 2 +- classes/EddLicenseActivationTracker.php | 2 +- classes/Integrations/CustomEmailCSS.php | 11 ++++++++--- classes/Migrations/FixDripMergeTags.php | 12 +++++++++++- classes/SmartLinkHandler.php | 4 ++-- fluent-crm-custom-features.php | 16 ++++++++++------ 6 files changed, 33 insertions(+), 14 deletions(-) diff --git a/classes/Actions/UpdateContactPropertyAction.php b/classes/Actions/UpdateContactPropertyAction.php index 510b062..00f11bd 100644 --- a/classes/Actions/UpdateContactPropertyAction.php +++ b/classes/Actions/UpdateContactPropertyAction.php @@ -310,7 +310,7 @@ public function formatCustomFieldValues( $values, $fields = [] ) { if ( ! is_array( $value ) && $is_array_type ) { $item_values = explode( ',', $value ); - $trimmed_values = array_map( 'trim', $item_values ); + $trimmed_values = array_filter( array_map( 'trim', $item_values ), 'strlen' ); if ( $trimmed_values ) { $values[ $value_key ] = $trimmed_values; } diff --git a/classes/EddLicenseActivationTracker.php b/classes/EddLicenseActivationTracker.php index e534703..7dc64a5 100644 --- a/classes/EddLicenseActivationTracker.php +++ b/classes/EddLicenseActivationTracker.php @@ -36,7 +36,7 @@ public function trackActivation( int $license_id, int $download_id ): void { $customer = new \EDD_Customer( $license->customer_id ); - if ( ! $customer || ! $customer->email ) { + if ( ! $customer->id || ! $customer->email ) { return; } diff --git a/classes/Integrations/CustomEmailCSS.php b/classes/Integrations/CustomEmailCSS.php index fed06c7..aa50c63 100644 --- a/classes/Integrations/CustomEmailCSS.php +++ b/classes/Integrations/CustomEmailCSS.php @@ -119,7 +119,8 @@ public function handle_save(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'fluent-crm-custom-features' ), - 403 + esc_html__( 'Forbidden', 'fluent-crm-custom-features' ), + [ 'response' => 403 ] ); } @@ -148,7 +149,8 @@ public function render_page(): void { if ( ! current_user_can( 'manage_options' ) ) { wp_die( esc_html__( 'You do not have permission to access this page.', 'fluent-crm-custom-features' ), - 403 + esc_html__( 'Forbidden', 'fluent-crm-custom-features' ), + [ 'response' => 403 ] ); } @@ -259,7 +261,10 @@ private function sanitize_css( string $css ): string { '/behavior\s*:/i', ]; - $css = preg_replace( $dangerous, '', $css ); + $css = preg_replace( $dangerous, '', $css ) ?? ''; + + // Prevent style tag breakout. + $css = str_replace( 'settings ); if ( ! is_array( $settings ) || empty( $settings['email_body'] ) ) { + ++$stats['skipped']; continue; } @@ -166,14 +167,23 @@ public static function run( bool $dry_run = false ): array { $updated = self::convertMergeTags( $original ); if ( $updated === $original ) { + ++$stats['skipped']; continue; } if ( ! $dry_run ) { $settings['email_body'] = $updated; - $db->table( 'fc_funnel_sequences' ) + $result = $db->table( 'fc_funnel_sequences' ) ->where( 'id', $seq->id ) ->update( [ 'settings' => maybe_serialize( $settings ) ] ); + + if ( $result !== false ) { + ++$stats['updated']; + } else { + ++$stats['errors']; + } + } else { + ++$stats['updated']; } } diff --git a/classes/SmartLinkHandler.php b/classes/SmartLinkHandler.php index dbf39e0..4aab644 100644 --- a/classes/SmartLinkHandler.php +++ b/classes/SmartLinkHandler.php @@ -95,8 +95,8 @@ public function getTargetUrl( $smart_link, $contact ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- Reading query params for smart link redirect, nonce not applicable. $query_params = array_diff_key( $_GET, array_flip( $ignored_params ) ); - // Sanitize all forwarded query parameters. - $query_params = array_map( 'sanitize_text_field', $query_params ); + // Sanitize all forwarded query parameters (handles nested arrays). + $query_params = map_deep( $query_params, 'sanitize_text_field' ); $query_string = http_build_query( $query_params ); $target_url = $smart_link->target_url; diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 10b663e..5e4e8db 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -104,8 +104,12 @@ function customcrm_validate_date_param( $value ) { return true; } - // Must match YYYY-MM-DD format. - return (bool) preg_match( '/^\d{4}-\d{2}-\d{2}$/', $value ); + // Must match YYYY-MM-DD format and be a valid date. + if ( ! preg_match( '/^(\d{4})-(\d{2})-(\d{2})$/', $value, $matches ) ) { + return false; + } + + return checkdate( (int) $matches[2], (int) $matches[3], (int) $matches[1] ); } /** @@ -125,13 +129,13 @@ function customcrm_get_list_growth( WP_REST_Request $request ) { // Count new subscribers. $new_subscribers = fluentCrmDb()->table( 'fc_subscribers' ) - ->whereBetween( 'created_at', [ $from, $to ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'status', 'subscribed' ) ->count(); // Count unsubscribed. $unsubscribed = fluentCrmDb()->table( 'fc_subscriber_meta' ) - ->whereBetween( 'created_at', [ $from, $to ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'key', 'unsubscribe_reason' ) ->count(); @@ -164,12 +168,12 @@ function customcrm_add_dashboard_list_growth_metrics( $data ) { // Calculate new subscribers and unsubscribes. $new_subscribers = fluentCrmDb()->table( 'fc_subscribers' ) - ->whereBetween( 'created_at', [ $from, $to ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'status', 'subscribed' ) ->count(); $unsubscribed = fluentCrmDb()->table( 'fc_subscriber_meta' ) - ->whereBetween( 'created_at', [ $from, $to ] ) + ->whereBetween( 'created_at', [ $from . ' 00:00:00', $to . ' 23:59:59' ] ) ->where( 'key', 'unsubscribe_reason' ) ->count();