From d0d7f87771f695678c783f9a1b8f66f6d38a90e9 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Mon, 2 Feb 2026 10:51:47 -0500 Subject: [PATCH 01/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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/30] 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(); From 836f7534e5e9405ea237cf7e1c1ea69591fd7d20 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 19 Mar 2026 16:44:02 -0400 Subject: [PATCH 15/30] fix: resolve array * int error blocking all FluentCRM automations array_merge_recursive() in getBlock() merged parent and child wait_time_amount into an array (['', 1]) instead of overwriting. When setDelayInSeconds() multiplied this by 60, PHP threw "Unsupported operand types: array * int", failing every fluentcrm_scheduled_every_minute_tasks cron run. - Replace array_merge_recursive with array_replace_recursive - Add defensive numeric casting in setDelayInSeconds() Fixes WEBSITE-135 --- classes/Actions/RandomWaitTimeAction.php | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/classes/Actions/RandomWaitTimeAction.php b/classes/Actions/RandomWaitTimeAction.php index 1997daa..2f53e3f 100644 --- a/classes/Actions/RandomWaitTimeAction.php +++ b/classes/Actions/RandomWaitTimeAction.php @@ -47,7 +47,7 @@ public function getBlock() { ], ]; - return array_merge_recursive( $block, $customize_block ); + return array_replace_recursive( $block, $customize_block ); } /** @@ -161,6 +161,11 @@ public function setDelayInSeconds( $delay_in_seconds, $settings, $sequence, $fun $max = Arr::get( $settings, 'wait_time_amount_max', 0 ); $unit = Arr::get( $settings, 'wait_time_unit' ); + // Ensure numeric types — array_merge_recursive can turn scalars into arrays. + $delay = is_numeric( $delay ) ? (float) $delay : 0; + $min = is_numeric( $min ) ? (float) $min : null; + $max = is_numeric( $max ) ? (float) $max : 0; + $wait_times = $delay; if ( $min >= 0 && $max > 0 ) { From 191d153dc3fa04c3b4e027ce9a891e0c5ff26b9f Mon Sep 17 00:00:00 2001 From: Vlad <2430296+mrcasual@users.noreply.github.com> Date: Fri, 20 Mar 2026 09:53:03 -0400 Subject: [PATCH 16/30] Add adversarial guards and correct Git Updater headers - Add early bail-out in setDelayInSeconds() for non-random sequences - Add numeric type guards in savingAction() before arithmetic - Point GitHub Plugin URI to GravityKit fork (active development repo) - Set Primary Branch to feature/github-updater-headers (production branch) --- classes/Actions/RandomWaitTimeAction.php | 9 +++++++++ fluent-crm-custom-features.php | 6 +++--- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/classes/Actions/RandomWaitTimeAction.php b/classes/Actions/RandomWaitTimeAction.php index 2f53e3f..7fda92e 100644 --- a/classes/Actions/RandomWaitTimeAction.php +++ b/classes/Actions/RandomWaitTimeAction.php @@ -63,6 +63,10 @@ public function savingAction( $sequence, $funnel ) { $max = Arr::get( $sequence, 'settings.wait_time_amount_max' ); $unit = Arr::get( $sequence, 'settings.wait_time_unit' ); + // Ensure numeric types before arithmetic. + $min = is_numeric( $min ) ? (float) $min : 0; + $max = is_numeric( $max ) ? (float) $max : 0; + if ( $min >= 0 && $max > 0 ) { $sequence['settings']['wait_time_amount'] = $max; @@ -156,6 +160,11 @@ public function getBlockFields() { * @return int */ public function setDelayInSeconds( $delay_in_seconds, $settings, $sequence, $funnel_subscriber_id ) { + // Only process sequences that have random wait settings. + if ( ! Arr::get( $settings, 'wait_time_amount_min' ) && ! Arr::get( $settings, 'wait_time_amount_max' ) ) { + return $delay_in_seconds; + } + $delay = Arr::get( $settings, 'wait_time_amount', null ); $min = Arr::get( $settings, 'wait_time_amount_min', null ); $max = Arr::get( $settings, 'wait_time_amount_max', 0 ); diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 5e4e8db..032d202 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -1,15 +1,15 @@ Date: Thu, 26 Mar 2026 16:34:17 -0400 Subject: [PATCH 17/30] docs: add design spec for provider-agnostic contact enrichment action Covers architecture, field mapping, execution flow, error handling, settings management, and custom field auto-creation. First provider implementation will be People Data Labs (PDL). --- .../2026-03-26-enrichment-action-design.md | 384 ++++++++++++++++++ 1 file changed, 384 insertions(+) create mode 100644 docs/superpowers/specs/2026-03-26-enrichment-action-design.md diff --git a/docs/superpowers/specs/2026-03-26-enrichment-action-design.md b/docs/superpowers/specs/2026-03-26-enrichment-action-design.md new file mode 100644 index 0000000..769b6a7 --- /dev/null +++ b/docs/superpowers/specs/2026-03-26-enrichment-action-design.md @@ -0,0 +1,384 @@ +# Enrich Contact Action Block — Design Spec + +## Overview + +A provider-agnostic FluentCRM automation action block that enriches contacts and companies using external data providers. The first provider implementation is People Data Labs (PDL). The action maps enrichment data to FluentCRM Subscriber fields, custom fields, and Company records. + +## Goals + +- Automatically enrich contacts with job title, company, social profiles, location, and demographic data +- Auto-create and link FluentCRM Companies from enrichment data or email domain +- Support both contact-triggered and company-triggered automations +- Design for swappable providers — PDL is the first, but the architecture supports Clearbit, Apollo, ZoomInfo, etc. +- Store enrichment metadata generically (no provider-specific prefixes in field names) + +## Architecture: Approach A — Single Action Block + +One action block handles both person and company enrichment. When scope includes both, it calls Person Enrichment first, then Company Enrichment using data from the person response (or email domain). Two sequential API calls in a single action step. + +**Why this approach:** +- The common case (enrich both) is the default and easiest to configure +- Sequential API calls add <1s total — invisible in FluentCRM's async cron execution +- Company enrichment benefits from person enrichment data (job_company_* fields) +- Simpler automation flows — one block instead of two + +## Action Block Settings + +**Action Name:** `enrich_contact` +**Category:** CRM +**Title:** Enrich Contact + +| Setting | Type | Default | Description | +|---|---|---|---| +| Provider | Dropdown | First configured | Which enrichment provider to use | +| Enrichment Scope | Radio | `both` | "Person + Company" / "Person Only" / "Company Only" | +| Min Likelihood | Number (1-10) | `6` | Minimum provider confidence score to accept a match | +| Data Behavior | Radio | `fill_empty` | "Fill empty fields only" / "Overwrite all fields" | +| Reprocess | Checkbox | unchecked | "Re-enrich contacts that have already been enriched" | +| Company Handling | Radio | `create_or_update` | "Create or update company" / "Update existing only" / "Don't touch companies" | +| Tag on Success | Tag selector | empty | Optional tag to apply after successful enrichment | +| Tag on No Match | Tag selector | empty | Optional tag to apply when provider returns no match | + +### Trigger Context Behavior + +The action adapts based on the automation trigger: + +| Trigger Context | Scope = "Person + Company" | Scope = "Person Only" | Scope = "Company Only" | +|---|---|---|---| +| Contact-triggered | Enrich person, then company | Enrich person only | Enrich company from email domain | +| Company-triggered | Degrades to Company Only | Marks sequence as skipped | Enrich company from name/website | + +**Context detection:** FluentCRM's `handle()` always receives a `$subscriber`. For company-triggered funnels, the subscriber may be a placeholder or the company owner. The action detects company context by checking: (1) whether `$subscriber->company_id` is set and `$subscriber->email` is empty or a placeholder, or (2) by inspecting the funnel trigger type via `$sequence->funnel->trigger_name`. If the trigger is company-based (e.g., `company_created`), the action loads the Company model from the subscriber's `company_id` and uses it as the primary enrichment target. This detection logic should be verified against FluentCRM's actual company-trigger implementation during development. + +### Company Match Confidence + +When a company is identified and linked to a contact: + +- **Confirmed**: Email domain matches the company's website domain (`jane@acme.com` -> company website is `acme.com`) +- **Inferred**: Free email provider (`jane@gmail.com`) but enrichment says she works at Acme Corp. Company is created/linked but marked as inferred. + +Stored in the `enrichment_company_match` custom field on the contact and in the company's `meta` JSON column. + +## Provider Abstraction + +### Interface: EnrichmentProvider + +```php +abstract class EnrichmentProvider { + abstract public function getSlug(): string; + abstract public function getName(): string; + abstract public function enrichPerson(array $params): PersonResult|EnrichmentError; + abstract public function enrichCompany(array $params): CompanyResult|EnrichmentError; + abstract public function getSettingsFields(): array; + abstract public function validateSettings(array $settings): bool|string; + abstract protected function mapError(int $httpStatus, array $responseBody): EnrichmentError; +} +``` + +### Error Handling + +All providers map their HTTP responses to a normalized `EnrichmentError`: + +```php +class EnrichmentError { + public string $code; // Normalized error code + public string $message; // Human-readable message + public ?int $httpStatus; // Raw HTTP status from provider + public bool $retryable; // Whether the action should retry +} +``` + +**Normalized error codes:** + +| Code | Meaning | PDL HTTP Status | Retryable | +|---|---|---|---| +| `no_match` | Entity not found | 404 | No | +| `invalid_input` | Bad/insufficient input params | 400 | No | +| `auth_failed` | Bad API key | 401 | No | +| `rate_limited` | Too many requests | 429 | Yes | +| `quota_exceeded` | Billing/credit limit hit | 402, 403 | No | +| `provider_error` | Upstream API failure | 5xx | Yes | +| `network_error` | Connection timeout, DNS failure | N/A | Yes | + +**Action behavior per error:** + +| Error code | Action behavior | +|---|---| +| `no_match` | Apply no-match tag, continue to next step | +| `invalid_input` | Log warning, mark sequence as skipped | +| `auth_failed` | Log error, mark skipped permanently | +| `rate_limited` | Log warning, mark skipped (FluentCRM retries on next cron) | +| `quota_exceeded` | Log error, mark skipped | +| `provider_error` | Log error, mark skipped (retryable on next run) | +| `network_error` | Log warning, mark skipped (retryable) | + +The action never retries inline. For retryable errors, FluentCRM's cron-based retry handles re-execution. For non-retryable errors, the contact is marked skipped with a logged note. + +All API calls and errors are logged via `fluentcrm_log()`. + +### PDL Provider Implementation + +**Person Enrichment:** +- Endpoint: `GET https://api.peopledatalabs.com/v5/person/enrich` +- Input: subscriber email (primary), plus first_name, last_name, company, location if available +- Min input: email alone is sufficient +- Returns 100+ fields including `job_company_*` data + +**Company Enrichment:** +- Endpoint: `GET https://api.peopledatalabs.com/v5/company/enrich` +- Input: website (primary), name (fallback), linkedin_url (fallback) +- Min input: one of name, website, ticker, or profile + +**Billing:** PDL charges per match (200 response only). 404s are free. + +**Rate Limits:** +- Free tier: 100/min (person), 10/min (company) +- Paid tier: 1,000/min both + +## Field Mapping + +### PersonResult DTO -> Subscriber (native fields) + +| PersonResult field | Subscriber field | PDL source field | +|---|---|---| +| `first_name` | `first_name` | `first_name` | +| `last_name` | `last_name` | `last_name` | +| `phone` | `phone` | `mobile_phone` or `phone_numbers[0]` | +| `city` | `city` | `location_locality` | +| `state` | `state` | `location_region` | +| `country` | `country` | `location_country` | +| `postal_code` | `postal_code` | `location_postal_code` | +| `address_line_1` | `address_line_1` | `location_street_address` | +| `date_of_birth` | `date_of_birth` | `birth_date` | +| `timezone` | `timezone` | Inferred from location | +| `latitude` | `latitude` | From `location_geo` | +| `longitude` | `longitude` | From `location_geo` | +| `avatar` | `avatar` | Profile photo URL if available | + +### PersonResult DTO -> Custom Fields + +| PersonResult field | Custom field slug | Type | +|---|---|---| +| `job_title` | `enrichment_job_title` | text | +| `job_role` | `enrichment_job_role` | text | +| `job_level` | `enrichment_job_level` | text | +| `job_company_name` | `enrichment_company_name` | text | +| `linkedin_url` | `enrichment_linkedin_url` | text | +| `twitter_url` | `enrichment_twitter_url` | text | +| `facebook_url` | `enrichment_facebook_url` | text | +| `github_url` | `enrichment_github_url` | text | +| `sex` | `enrichment_sex` | select-one | +| `pronouns` | `enrichment_pronouns` | text | +| `inferred_salary` | `enrichment_inferred_salary` | text | +| `industry` | `enrichment_industry` | text | +| `enriched_at` | `enriched_at` | date_time | +| `enrichment_provider` | `enrichment_provider` | text | +| `enrichment_likelihood` | `enrichment_likelihood` | number | +| `enrichment_company_match` | `enrichment_company_match` | select-one | + +The `enrichment_pronouns` field can be auto-populated by the provider from `sex` data (he/him, she/her) or directly if the provider returns pronoun data. This mapping is a provider-level decision, customizable via the `custom_crm/enrichment_person_result` filter. + +### CompanyResult DTO -> Company (native fields) + +| CompanyResult field | Company field | PDL source field | +|---|---|---| +| `name` | `name` | `display_name` | +| `industry` | `industry` | `industry` | +| `type` | `type` | `type` (public/private/nonprofit) | +| `website` | `website` | `website` | +| `email` | `email` | If available | +| `phone` | `phone` | If available | +| `address_line_1` | `address_line_1` | `location.street_address` | +| `city` | `city` | `location.locality` | +| `state` | `state` | `location.region` | +| `country` | `country` | `location.country` | +| `postal_code` | `postal_code` | `location.postal_code` | +| `employees_number` | `employees_number` | `employee_count` | +| `description` | `description` | `summary` | +| `logo` | `logo` | If available | +| `linkedin_url` | `linkedin_url` | `linkedin_url` | +| `facebook_url` | `facebook_url` | `facebook_url` | +| `twitter_url` | `twitter_url` | `twitter_url` | +| `date_of_start` | `date_of_start` | `founded` (year) | + +### CompanyResult DTO -> Company meta (JSON column) + +| Field | PDL source | +|---|---| +| `enrichment_company_match` | `confirmed` or `inferred` | +| `enrichment_provider` | Provider slug (e.g., "pdl") | +| `enriched_at` | Timestamp | +| `funding_raised` | `total_funding_raised` | +| `funding_stage` | `latest_funding_stage` | +| `inferred_revenue` | `inferred_revenue` | +| `employee_growth_rate_12mo` | `employee_growth_rate.12_month` | +| `ticker` | Stock ticker if public | +| `naics_codes` | Industry classification codes | + +## Execution Flow + +``` +1. DETECT CONTEXT + +-- Contact-triggered? -> have subscriber email + +-- Company-triggered? -> have company name/website + +2. CHECK SKIP CONDITIONS + +-- Reprocess disabled + contact has enriched_at? -> skip + +-- No API key configured for selected provider? -> skip + log warning + +3. RESOLVE PROVIDER + +-- Instantiate provider from setting (default: PDL) + +4. PERSON ENRICHMENT (if scope includes person + have subscriber) + +-- Call provider.enrichPerson(email, first_name, last_name, ...) + +-- Error or below min_likelihood? + | +-- Apply "no match" tag if configured + | +-- Continue to company step (may still work from email domain) + +-- Success + above threshold? + +-- Map PersonResult -> Subscriber fields (respecting fill_empty/overwrite) + +-- Set enriched_at and enrichment_provider custom fields + +-- Stash job_company_name, job_company_website for company step + +5. COMPANY ENRICHMENT (if scope includes company) + +-- Determine company identifier: + | +-- Company-triggered? -> use company.website or company.name + | +-- Contact has business email? -> extract domain -> use as website + | +-- Free email? -> use job_company_website from step 4 (if available) + +-- No identifier found? -> skip company enrichment + +-- Call provider.enrichCompany(name, website, ...) + +-- Error? -> skip + +-- Success? + +-- Determine match confidence: + | +-- Email domain == company website domain? -> "confirmed" + | +-- Otherwise -> "inferred" + +-- Based on Company Handling setting: + +-- "Create or update" -> + | +-- Find existing Company by website domain + | +-- Create if not found, update if found + | +-- Store match confidence in company meta + | +-- Link subscriber <-> company (pivot + company_id) + +-- "Update existing only" -> + | +-- Update company if subscriber already linked to one + +-- "Don't touch companies" -> + +-- Store company data in contact custom fields only + +6. APPLY TAGS + +-- Enrichment succeeded? -> apply success tag if configured + +-- Both person + company failed? -> apply no-match tag if configured + +7. FIRE HOOKS + +-- do_action('custom_crm/contact_enriched', $subscriber, $personResult, $companyResult) + +-- do_action('custom_crm/company_enriched', $company, $companyResult) +``` + +### Free Email Detection + +A helper checks the email domain against a list of known free providers (gmail.com, yahoo.com, hotmail.com, outlook.com, icloud.com, aol.com, protonmail.com, etc.). The list is filterable: + +```php +apply_filters('custom_crm/free_email_domains', $domains) +``` + +### Company Matching + +When looking for an existing FluentCRM Company, match by normalized website domain (strip protocol, `www.`, trailing slash). If no website match, fall back to exact name match. + +## Settings Page & API Key Management + +A settings section for provider configuration. Each registered provider contributes its own fields. + +### Database Structure + +```php +// Option key: custom_crm_enrichment_settings +[ + 'active_provider' => 'pdl', + 'providers' => [ + 'pdl' => [ + 'api_key' => '(encrypted)', + 'api_tier' => 'free', // free | paid + ], + ], +] +``` + +### Provider Settings Registration + +Each provider declares its settings fields and validation: + +```php +// In EnrichmentProvider (abstract) +abstract public function getSettingsFields(): array; +abstract public function validateSettings(array $settings): bool|string; +``` + +PDL declares: `api_key` (password field) and `api_tier` (select: free/paid). + +### Settings Page Features + +- **Test Connection** button per provider — calls `validateSettings()`, shows inline success/failure +- API key stored encrypted via `wp_encrypt()` (WP 6.5+) with fallback to site-salted encoding for older installs +- Provider dropdown in action block only shows providers with valid configured API keys +- If no providers configured, the action block shows a notice linking to settings + +## Custom Field Auto-Creation + +On plugin activation and lazily on first enrichment run, ensure all required FluentCRM custom fields exist: + +| Slug | Label | Type | Group | +|---|---|---|---| +| `enrichment_job_title` | Job Title | text | Enrichment | +| `enrichment_job_role` | Job Role | text | Enrichment | +| `enrichment_job_level` | Job Level | text | Enrichment | +| `enrichment_company_name` | Company Name | text | Enrichment | +| `enrichment_linkedin_url` | LinkedIn URL | text | Enrichment | +| `enrichment_twitter_url` | Twitter URL | text | Enrichment | +| `enrichment_facebook_url` | Facebook URL | text | Enrichment | +| `enrichment_github_url` | GitHub URL | text | Enrichment | +| `enrichment_sex` | Sex | select-one | Enrichment | +| `enrichment_pronouns` | Pronouns | text | Enrichment | +| `enrichment_inferred_salary` | Inferred Salary | text | Enrichment | +| `enrichment_industry` | Industry | text | Enrichment | +| `enriched_at` | Enriched At | date_time | Enrichment | +| `enrichment_provider` | Enrichment Provider | text | Enrichment | +| `enrichment_likelihood` | Match Likelihood | number | Enrichment | +| `enrichment_company_match` | Company Match Type | select-one | Enrichment | + +Uses `fluentcrm_get_option('contact_custom_fields')` / `fluentcrm_update_option()`. A transient prevents repeated DB reads after initial check. + +## File Structure + +``` +classes/ + Enrichment/ + EnrichmentProvider.php # Abstract provider contract + EnrichmentError.php # Normalized error DTO + PersonResult.php # Person data DTO + CompanyResult.php # Company data DTO + EnrichmentFields.php # Auto-creates FluentCRM custom fields + EnrichmentSettings.php # Read/write provider settings, encrypt API keys + FreeEmailDetector.php # Free email domain checker (filterable) + Providers/ + PDLProvider.php # People Data Labs implementation + Actions/ + EnrichContactAction.php # FluentCRM action block +``` + +## Hooks & Filters + +| Hook | Type | Purpose | +|---|---|---| +| `custom_crm/enrichment_providers` | Filter | Register additional providers | +| `custom_crm/free_email_domains` | Filter | Add/remove free email domains | +| `custom_crm/enrichment_person_result` | Filter | Modify person DTO before saving (e.g., pronouns mapping) | +| `custom_crm/enrichment_company_result` | Filter | Modify company DTO before saving | +| `custom_crm/contact_enriched` | Action | Fires after contact enrichment completes | +| `custom_crm/company_enriched` | Action | Fires after company enrichment completes | + +## Stretch Goals (not in initial implementation) + +- Daily/monthly budget cap for API credits +- Cooldown period before re-enrichment +- Bulk enrichment UI (process existing contacts outside of automation flows) +- Additional providers (Clearbit, Apollo, ZoomInfo) +- Enrichment history log per contact (track changes over time) From daf1da77c948b68b830407048f7843733258320e Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 16:48:02 -0400 Subject: [PATCH 18/30] docs: add implementation plan for enrichment action 12-task plan covering DTOs, provider abstraction, PDL implementation, FluentCRM action block, settings, custom fields, and registration. --- .../plans/2026-03-26-enrichment-action.md | 2127 +++++++++++++++++ 1 file changed, 2127 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-26-enrichment-action.md diff --git a/docs/superpowers/plans/2026-03-26-enrichment-action.md b/docs/superpowers/plans/2026-03-26-enrichment-action.md new file mode 100644 index 0000000..563ac20 --- /dev/null +++ b/docs/superpowers/plans/2026-03-26-enrichment-action.md @@ -0,0 +1,2127 @@ +# Enrichment Action Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a provider-agnostic FluentCRM automation action block that enriches contacts and companies using People Data Labs (PDL) as the first provider. + +**Architecture:** Single action block with abstract provider interface. DTOs (PersonResult, CompanyResult) normalize API responses. PDLProvider maps PDL's API to the DTOs. EnrichContactAction orchestrates the flow: detect context, call provider, map results to FluentCRM Subscriber/Company fields. + +**Tech Stack:** PHP 7.4+, WordPress HTTP API (`wp_remote_get`), FluentCRM BaseAction, FluentCRM Subscriber/Company models + +**Spec:** `docs/superpowers/specs/2026-03-26-enrichment-action-design.md` + +--- + +## File Map + +| File | Responsibility | +|---|---| +| `classes/Enrichment/EnrichmentError.php` | Normalized error DTO | +| `classes/Enrichment/PersonResult.php` | Person data DTO | +| `classes/Enrichment/CompanyResult.php` | Company data DTO | +| `classes/Enrichment/EnrichmentProvider.php` | Abstract provider contract | +| `classes/Enrichment/FreeEmailDetector.php` | Free email domain checker | +| `classes/Enrichment/EnrichmentSettings.php` | Provider settings CRUD | +| `classes/Enrichment/EnrichmentFields.php` | Custom field auto-creation | +| `classes/Enrichment/Providers/PDLProvider.php` | PDL API implementation | +| `classes/Actions/EnrichContactAction.php` | FluentCRM action block | +| `fluent-crm-custom-features.php` | Register the action (modify) | + +--- + +### Task 1: EnrichmentError DTO + +**Files:** +- Create: `classes/Enrichment/EnrichmentError.php` + +- [ ] **Step 1: Create the error DTO class** + +```php +code = $code; + $this->message = $message; + $this->http_status = $http_status; + $this->retryable = $retryable; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/EnrichmentError.php +git commit -m "feat(enrichment): add EnrichmentError DTO" +``` + +--- + +### Task 2: PersonResult DTO + +**Files:** +- Create: `classes/Enrichment/PersonResult.php` + +- [ ] **Step 1: Create the person result DTO** + +```php + Only non-null values. + */ + public function toSubscriberFields(): array { + $map = [ + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'phone' => $this->phone, + 'city' => $this->city, + 'state' => $this->state, + 'country' => $this->country, + 'postal_code' => $this->postal_code, + 'address_line_1' => $this->address_line_1, + 'date_of_birth' => $this->date_of_birth, + 'timezone' => $this->timezone, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'avatar' => $this->avatar, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } + + /** + * Return fields that map to FluentCRM custom fields. + * + * @return array Only non-null values, keyed by custom field slug. + */ + public function toCustomFields(): array { + $map = [ + 'enrichment_job_title' => $this->job_title, + 'enrichment_job_role' => $this->job_role, + 'enrichment_job_level' => $this->job_level, + 'enrichment_company_name' => $this->job_company_name, + 'enrichment_linkedin_url' => $this->linkedin_url, + 'enrichment_twitter_url' => $this->twitter_url, + 'enrichment_facebook_url' => $this->facebook_url, + 'enrichment_github_url' => $this->github_url, + 'enrichment_sex' => $this->sex, + 'enrichment_pronouns' => $this->pronouns, + 'enrichment_inferred_salary' => $this->inferred_salary, + 'enrichment_industry' => $this->industry, + 'enrichment_likelihood' => $this->likelihood, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/PersonResult.php +git commit -m "feat(enrichment): add PersonResult DTO" +``` + +--- + +### Task 3: CompanyResult DTO + +**Files:** +- Create: `classes/Enrichment/CompanyResult.php` + +- [ ] **Step 1: Create the company result DTO** + +```php + Only non-null values. + */ + public function toCompanyFields(): array { + $map = [ + 'name' => $this->name, + 'industry' => $this->industry, + 'type' => $this->type, + 'website' => $this->website, + 'email' => $this->email, + 'phone' => $this->phone, + 'address_line_1' => $this->address_line_1, + 'city' => $this->city, + 'state' => $this->state, + 'country' => $this->country, + 'postal_code' => $this->postal_code, + 'employees_number' => $this->employees_number, + 'description' => $this->description, + 'logo' => $this->logo, + 'linkedin_url' => $this->linkedin_url, + 'facebook_url' => $this->facebook_url, + 'twitter_url' => $this->twitter_url, + 'date_of_start' => $this->date_of_start, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } + + /** + * Return extra fields for the Company meta JSON column. + * + * @return array Only non-null values. + */ + public function toCompanyMeta(): array { + $map = [ + 'funding_raised' => $this->funding_raised, + 'funding_stage' => $this->funding_stage, + 'inferred_revenue' => $this->inferred_revenue, + 'employee_growth_rate_12mo' => $this->employee_growth_rate_12mo, + 'ticker' => $this->ticker, + 'naics_codes' => $this->naics_codes, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/CompanyResult.php +git commit -m "feat(enrichment): add CompanyResult DTO" +``` + +--- + +### Task 4: EnrichmentProvider Abstract Class + +**Files:** +- Create: `classes/Enrichment/EnrichmentProvider.php` + +- [ ] **Step 1: Create the abstract provider** + +```php + $params Keys like 'email', 'first_name', 'last_name', 'company', etc. + * + * @return PersonResult|EnrichmentError + */ + abstract public function enrichPerson( array $params ); + + /** + * Enrich a company. + * + * @param array $params Keys like 'website', 'name', 'linkedin_url', etc. + * + * @return CompanyResult|EnrichmentError + */ + abstract public function enrichCompany( array $params ); + + /** + * Declare settings fields this provider needs (shown on settings page). + * + * @return array> Keyed by field name. + */ + abstract public function getSettingsFields(): array; + + /** + * Validate saved settings (e.g. test an API key). + * + * @param array $settings The provider's saved settings. + * + * @return true|string True on success, error message string on failure. + */ + abstract public function validateSettings( array $settings ); + + /** + * Map a provider-specific HTTP error to a normalized EnrichmentError. + * + * @param int $http_status HTTP status code. + * @param array $response_body Decoded response body. + * + * @return EnrichmentError + */ + abstract protected function mapError( int $http_status, array $response_body ): EnrichmentError; + + /** + * Make an HTTP GET request via WordPress HTTP API. + * + * @param string $url Full URL with query params. + * @param array $headers Request headers. + * + * @return array{status:int,body:array}|EnrichmentError + */ + protected function httpGet( string $url, array $headers = [] ) { + $response = wp_remote_get( + $url, + [ + 'headers' => $headers, + 'timeout' => 15, + ] + ); + + if ( is_wp_error( $response ) ) { + return new EnrichmentError( + EnrichmentError::NETWORK_ERROR, + $response->get_error_message(), + null, + true + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $body ) ) { + $body = []; + } + + if ( $status < 200 || $status >= 300 ) { + return $this->mapError( $status, $body ); + } + + return [ + 'status' => $status, + 'body' => $body, + ]; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/EnrichmentProvider.php +git commit -m "feat(enrichment): add abstract EnrichmentProvider with httpGet helper" +``` + +--- + +### Task 5: FreeEmailDetector + +**Files:** +- Create: `classes/Enrichment/FreeEmailDetector.php` + +- [ ] **Step 1: Create the free email detector** + +```php +>} + */ + public static function getAll(): array { + $defaults = [ + 'active_provider' => '', + 'providers' => [], + ]; + + $settings = get_option( self::OPTION_KEY, $defaults ); + + return wp_parse_args( $settings, $defaults ); + } + + /** + * Save all settings. + * + * @param array $settings Full settings array. + */ + public static function saveAll( array $settings ): void { + update_option( self::OPTION_KEY, $settings, false ); + } + + /** + * Get settings for a specific provider. + * + * @param string $slug Provider slug. + * + * @return array + */ + public static function getProviderSettings( string $slug ): array { + $all = self::getAll(); + + return $all['providers'][ $slug ] ?? []; + } + + /** + * Save settings for a specific provider. + * + * @param string $slug Provider slug. + * @param array $settings Provider-specific settings. + */ + public static function saveProviderSettings( string $slug, array $settings ): void { + $all = self::getAll(); + $all['providers'][ $slug ] = $settings; + + // Auto-set active provider if none set. + if ( empty( $all['active_provider'] ) ) { + $all['active_provider'] = $slug; + } + + self::saveAll( $all ); + } + + /** + * Get the active provider slug. + * + * @return string + */ + public static function getActiveProvider(): string { + $all = self::getAll(); + + return $all['active_provider'] ?? ''; + } + + /** + * Get the API key for a provider, decrypted. + * + * @param string $slug Provider slug. + * + * @return string API key or empty string. + */ + public static function getApiKey( string $slug ): string { + $settings = self::getProviderSettings( $slug ); + + $encrypted = $settings['api_key'] ?? ''; + + if ( '' === $encrypted ) { + return ''; + } + + return self::decrypt( $encrypted ); + } + + /** + * Encrypt a value for storage. + * + * Uses wp_encrypt() on WP 6.5+, falls back to base64 with site salt. + * + * @param string $value Plain text value. + * + * @return string Encrypted string. + */ + public static function encrypt( string $value ): string { + if ( function_exists( 'wp_encrypt' ) ) { + $result = wp_encrypt( $value ); + if ( ! is_wp_error( $result ) ) { + return $result; + } + } + + // Fallback: base64 with salt prefix for identification. + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + return 'b64:' . base64_encode( $value ); + } + + /** + * Decrypt a stored value. + * + * @param string $encrypted Encrypted string. + * + * @return string Decrypted value. + */ + public static function decrypt( string $encrypted ): string { + if ( str_starts_with( $encrypted, 'b64:' ) ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + return base64_decode( substr( $encrypted, 4 ) ); + } + + if ( function_exists( 'wp_decrypt' ) ) { + $result = wp_decrypt( $encrypted ); + if ( ! is_wp_error( $result ) ) { + return $result; + } + } + + // If we can't decrypt, return as-is (may be plain text from old storage). + return $encrypted; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/EnrichmentSettings.php +git commit -m "feat(enrichment): add EnrichmentSettings with encrypted API key storage" +``` + +--- + +### Task 7: EnrichmentFields (Custom Field Auto-Creation) + +**Files:** +- Create: `classes/Enrichment/EnrichmentFields.php` + +- [ ] **Step 1: Create the custom field manager** + +```php +> + */ + public static function getFieldDefinitions(): array { + return [ + [ + 'slug' => 'enrichment_job_title', + 'label' => 'Job Title', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_job_role', + 'label' => 'Job Role', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_job_level', + 'label' => 'Job Level', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_company_name', + 'label' => 'Company Name', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_linkedin_url', + 'label' => 'LinkedIn URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_twitter_url', + 'label' => 'Twitter URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_facebook_url', + 'label' => 'Facebook URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_github_url', + 'label' => 'GitHub URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_sex', + 'label' => 'Sex', + 'type' => 'select-one', + 'group' => 'Enrichment', + 'options' => [ 'male', 'female' ], + ], + [ + 'slug' => 'enrichment_pronouns', + 'label' => 'Pronouns', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_inferred_salary', + 'label' => 'Inferred Salary', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_industry', + 'label' => 'Industry', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enriched_at', + 'label' => 'Enriched At', + 'type' => 'date_time', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_provider', + 'label' => 'Enrichment Provider', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_likelihood', + 'label' => 'Match Likelihood', + 'type' => 'number', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_company_match', + 'label' => 'Company Match Type', + 'type' => 'select-one', + 'group' => 'Enrichment', + 'options' => [ 'confirmed', 'inferred' ], + ], + ]; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/EnrichmentFields.php +git commit -m "feat(enrichment): add EnrichmentFields for custom field auto-creation" +``` + +--- + +### Task 8: PDLProvider + +**Files:** +- Create: `classes/Enrichment/Providers/PDLProvider.php` + +- [ ] **Step 1: Create the PDL provider class** + +```php + [ + 'label' => __( 'API Key', 'fluent-crm-custom-features' ), + 'type' => 'password', + 'placeholder' => __( 'Your People Data Labs API key', 'fluent-crm-custom-features' ), + 'help' => __( 'Get your key at dashboard.peopledatalabs.com', 'fluent-crm-custom-features' ), + ], + 'api_tier' => [ + 'label' => __( 'Plan Tier', 'fluent-crm-custom-features' ), + 'type' => 'select', + 'options' => [ + 'free' => __( 'Free (100 req/min)', 'fluent-crm-custom-features' ), + 'paid' => __( 'Paid (1,000 req/min)', 'fluent-crm-custom-features' ), + ], + 'default' => 'free', + ], + ]; + } + + /** + * {@inheritDoc} + */ + public function validateSettings( array $settings ) { + $api_key = $settings['api_key'] ?? ''; + + if ( '' === $api_key ) { + return __( 'API key is required.', 'fluent-crm-custom-features' ); + } + + // Make a lightweight test call — search for a known test entity. + $url = add_query_arg( + [ + 'email' => 'sean@peopledatalabs.com', + 'min_likelihood' => 1, + 'data_include' => 'full_name', + 'pretty' => 'false', + ], + self::PERSON_ENDPOINT + ); + + $result = $this->httpGet( + $url, + [ 'X-Api-Key' => $api_key ] + ); + + if ( $result instanceof EnrichmentError ) { + if ( EnrichmentError::AUTH_FAILED === $result->code ) { + return __( 'Invalid API key.', 'fluent-crm-custom-features' ); + } + + return $result->message; + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function enrichPerson( array $params ) { + $api_key = $this->getApiKey(); + + if ( '' === $api_key ) { + return new EnrichmentError( + EnrichmentError::AUTH_FAILED, + 'PDL API key not configured.', + null, + false + ); + } + + $query = array_filter( + [ + 'email' => $params['email'] ?? '', + 'first_name' => $params['first_name'] ?? '', + 'last_name' => $params['last_name'] ?? '', + 'company' => $params['company'] ?? '', + 'location' => $params['location'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 6, + 'include_if_matched' => 'true', + 'titlecase' => 'true', + 'pretty' => 'false', + ], + static fn( $v ) => '' !== $v && null !== $v + ); + + $url = add_query_arg( $query, self::PERSON_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + + if ( $result instanceof EnrichmentError ) { + return $result; + } + + return $this->mapPersonResponse( $result['body'] ); + } + + /** + * {@inheritDoc} + */ + public function enrichCompany( array $params ) { + $api_key = $this->getApiKey(); + + if ( '' === $api_key ) { + return new EnrichmentError( + EnrichmentError::AUTH_FAILED, + 'PDL API key not configured.', + null, + false + ); + } + + $query = array_filter( + [ + 'website' => $params['website'] ?? '', + 'name' => $params['name'] ?? '', + 'profile' => $params['linkedin_url'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 2, + 'include_if_matched' => 'true', + 'titlecase' => 'true', + 'pretty' => 'false', + ], + static fn( $v ) => '' !== $v && null !== $v + ); + + $url = add_query_arg( $query, self::COMPANY_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + + if ( $result instanceof EnrichmentError ) { + return $result; + } + + return $this->mapCompanyResponse( $result['body'] ); + } + + /** + * {@inheritDoc} + */ + protected function mapError( int $http_status, array $response_body ): EnrichmentError { + $message = $response_body['error']['message'] ?? "HTTP {$http_status}"; + + return match ( $http_status ) { + 400 => new EnrichmentError( EnrichmentError::INVALID_INPUT, $message, 400, false ), + 401 => new EnrichmentError( EnrichmentError::AUTH_FAILED, $message, 401, false ), + 402,403 => new EnrichmentError( EnrichmentError::QUOTA_EXCEEDED, $message, $http_status, false ), + 404 => new EnrichmentError( EnrichmentError::NO_MATCH, 'No matching record found.', 404, false ), + 429 => new EnrichmentError( EnrichmentError::RATE_LIMITED, $message, 429, true ), + default => new EnrichmentError( + $http_status >= 500 ? EnrichmentError::PROVIDER_ERROR : EnrichmentError::PROVIDER_ERROR, + $message, + $http_status, + $http_status >= 500 + ), + }; + } + + /** + * Map PDL person response to PersonResult DTO. + * + * @param array $body Decoded PDL response body. + * + * @return PersonResult + */ + private function mapPersonResponse( array $body ): PersonResult { + $data = $body['data'] ?? $body; + $result = new PersonResult(); + + $result->first_name = $data['first_name'] ?? null; + $result->last_name = $data['last_name'] ?? null; + $result->phone = $data['mobile_phone'] ?? ( $data['phone_numbers'][0] ?? null ); + $result->city = $data['location_locality'] ?? null; + $result->state = $data['location_region'] ?? null; + $result->country = $data['location_country'] ?? null; + $result->postal_code = $data['location_postal_code'] ?? null; + $result->address_line_1 = $data['location_street_address'] ?? null; + $result->date_of_birth = $data['birth_date'] ?? null; + $result->linkedin_url = $data['linkedin_url'] ?? null; + $result->twitter_url = $data['twitter_url'] ?? null; + $result->facebook_url = $data['facebook_url'] ?? null; + $result->github_url = $data['github_url'] ?? null; + $result->job_title = $data['job_title'] ?? null; + $result->job_role = $data['job_title_role'] ?? null; + $result->industry = $data['industry'] ?? ( $data['job_company_industry'] ?? null ); + $result->inferred_salary = $data['inferred_salary'] ?? null; + $result->sex = $data['sex'] ?? null; + + // Derive pronouns from sex if available. + if ( $result->sex && null === $result->pronouns ) { + $result->pronouns = match ( strtolower( $result->sex ) ) { + 'male' => 'he/him', + 'female' => 'she/her', + default => null, + }; + } + + // Job level: PDL returns an array, take the first. + $levels = $data['job_title_levels'] ?? []; + $result->job_level = is_array( $levels ) && $levels ? $levels[0] : null; + + // Company data from person response. + $result->job_company_name = $data['job_company_name'] ?? null; + $result->job_company_website = $data['job_company_website'] ?? null; + + // Geo coordinates. + $geo = $data['location_geo'] ?? null; + if ( $geo && is_string( $geo ) && str_contains( $geo, ',' ) ) { + $parts = explode( ',', $geo ); + $result->latitude = (float) trim( $parts[0] ); + $result->longitude = (float) trim( $parts[1] ); + } + + $result->likelihood = $body['likelihood'] ?? null; + + /** @var PersonResult */ + return apply_filters( 'custom_crm/enrichment_person_result', $result, $body ); + } + + /** + * Map PDL company response to CompanyResult DTO. + * + * @param array $body Decoded PDL response body. + * + * @return CompanyResult + */ + private function mapCompanyResponse( array $body ): CompanyResult { + // PDL company responses put fields at root level (not nested under 'data'). + $data = $body; + $result = new CompanyResult(); + + $result->name = $data['display_name'] ?? ( $data['name'] ?? null ); + $result->industry = $data['industry'] ?? null; + $result->type = $data['type'] ?? null; + $result->website = $data['website'] ?? null; + $result->phone = $data['phone'] ?? null; + $result->linkedin_url = $data['linkedin_url'] ?? null; + $result->facebook_url = $data['facebook_url'] ?? null; + $result->twitter_url = $data['twitter_url'] ?? null; + $result->employees_number = $data['employee_count'] ?? null; + $result->description = $data['summary'] ?? null; + $result->logo = $data['logo'] ?? null; + $result->ticker = $data['ticker'] ?? null; + $result->inferred_revenue = $data['inferred_revenue'] ?? null; + $result->funding_raised = isset( $data['total_funding_raised'] ) ? (int) $data['total_funding_raised'] : null; + $result->funding_stage = $data['latest_funding_stage'] ?? null; + + // Founded year -> date_of_start. + if ( ! empty( $data['founded'] ) ) { + $result->date_of_start = (string) $data['founded']; + } + + // Location fields. + $location = $data['location'] ?? []; + if ( is_array( $location ) ) { + $result->address_line_1 = $location['street_address'] ?? null; + $result->city = $location['locality'] ?? null; + $result->state = $location['region'] ?? null; + $result->country = $location['country'] ?? null; + $result->postal_code = $location['postal_code'] ?? null; + } + + // Employee growth rate. + $growth = $data['employee_growth_rate'] ?? []; + if ( is_array( $growth ) && isset( $growth['12_month'] ) ) { + $result->employee_growth_rate_12mo = (float) $growth['12_month']; + } + + // NAICS codes. + $result->naics_codes = $data['naics'] ?? null; + + $result->likelihood = $body['likelihood'] ?? null; + + /** @var CompanyResult */ + return apply_filters( 'custom_crm/enrichment_company_result', $result, $body ); + } + + /** + * Get the decrypted API key for this provider. + * + * @return string + */ + private function getApiKey(): string { + return EnrichmentSettings::getApiKey( $this->getSlug() ); + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Enrichment/Providers/PDLProvider.php +git commit -m "feat(enrichment): add PDLProvider with person and company enrichment" +``` + +--- + +### Task 9: EnrichContactAction — FluentCRM Action Block + +**Files:** +- Create: `classes/Actions/EnrichContactAction.php` + +- [ ] **Step 1: Create the action block class** + +This is the largest file. It implements getBlock(), getBlockFields(), and handle() per FluentCRM's BaseAction pattern. + +```php + + */ + private array $providers = []; + + public function __construct() { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $this->actionName = 'enrich_contact'; + $this->priority = 30; + parent::__construct(); + } + + /** + * Get block definition shown in the automation editor sidebar. + * + * @return array + */ + public function getBlock() { + return [ + 'category' => __( 'CRM', 'fluent-crm-custom-features' ), + 'title' => __( 'Enrich Contact', 'fluent-crm-custom-features' ), + 'description' => __( 'Enrich contact and company data using an external provider', 'fluent-crm-custom-features' ), + 'icon' => 'fc-icon-wp_user_meta', + 'settings' => [ + 'provider' => EnrichmentSettings::getActiveProvider(), + 'enrichment_scope' => 'both', + 'min_likelihood' => 6, + 'data_behavior' => 'fill_empty', + 'reprocess' => 'no', + 'company_handling' => 'create_or_update', + 'tag_on_success' => [], + 'tag_on_no_match' => [], + ], + ]; + } + + /** + * Get block field definitions for the settings editor. + * + * @return array + */ + public function getBlockFields() { + return [ + 'title' => __( 'Enrich Contact', 'fluent-crm-custom-features' ), + 'sub_title' => __( 'Enrich contact and company data using an external provider', 'fluent-crm-custom-features' ), + 'fields' => [ + 'provider' => [ + 'type' => 'select', + 'label' => __( 'Enrichment Provider', 'fluent-crm-custom-features' ), + 'options' => $this->getProviderOptions(), + 'inline_help' => __( 'Configure providers in FluentCRM Custom Features settings.', 'fluent-crm-custom-features' ), + ], + 'enrichment_scope' => [ + 'type' => 'radio', + 'label' => __( 'Enrichment Scope', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'both', + 'title' => __( 'Person + Company', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'person', + 'title' => __( 'Person Only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'company', + 'title' => __( 'Company Only', 'fluent-crm-custom-features' ), + ], + ], + ], + 'min_likelihood' => [ + 'type' => 'input-number', + 'label' => __( 'Minimum Likelihood Score (1-10)', 'fluent-crm-custom-features' ), + 'wrapper_class' => 'fc_2col_inline pad-r-20', + 'inline_help' => __( 'Higher = more accurate but fewer matches. Recommended: 6.', 'fluent-crm-custom-features' ), + ], + 'data_behavior' => [ + 'type' => 'radio', + 'label' => __( 'Data Behavior', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'fill_empty', + 'title' => __( 'Fill empty fields only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'overwrite', + 'title' => __( 'Overwrite all fields', 'fluent-crm-custom-features' ), + ], + ], + ], + 'reprocess' => [ + 'type' => 'yes_no_check', + 'check_label' => __( 'Re-enrich contacts that have already been enriched', 'fluent-crm-custom-features' ), + ], + 'company_handling' => [ + 'type' => 'radio', + 'label' => __( 'Company Handling', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'create_or_update', + 'title' => __( 'Create or update company', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'update_existing', + 'title' => __( 'Update existing company only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'none', + 'title' => __( "Don't touch companies", 'fluent-crm-custom-features' ), + ], + ], + 'dependency' => [ + 'depends_on' => 'enrichment_scope', + 'value' => 'person', + 'operator' => '!=', + ], + ], + 'tag_on_success' => [ + 'type' => 'option_selectors', + 'option_key' => 'tags', + 'is_multiple' => true, + 'label' => __( 'Tag on Successful Enrichment', 'fluent-crm-custom-features' ), + 'placeholder' => __( 'Select Tags (optional)', 'fluent-crm-custom-features' ), + ], + 'tag_on_no_match' => [ + 'type' => 'option_selectors', + 'option_key' => 'tags', + 'is_multiple' => true, + 'label' => __( 'Tag on No Match', 'fluent-crm-custom-features' ), + 'placeholder' => __( 'Select Tags (optional)', 'fluent-crm-custom-features' ), + ], + ], + ]; + } + + /** + * Execute the enrichment action. + * + * @param \FluentCrm\App\Models\Subscriber $subscriber The subscriber. + * @param \FluentCrm\App\Models\FunnelSequence $sequence The funnel sequence. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $funnel_metric Funnel metric. + */ + public function handle( $subscriber, $sequence, $funnel_subscriber_id, $funnel_metric ) { + $settings = $sequence->settings; + $scope = Arr::get( $settings, 'enrichment_scope', 'both' ); + + // --- 1. Detect context --- + $is_company_trigger = $this->isCompanyTriggered( $subscriber, $sequence ); + + if ( $is_company_trigger && 'person' === $scope ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + + if ( $is_company_trigger ) { + $scope = 'company'; + } + + // --- 2. Check skip conditions --- + $reprocess = 'yes' === Arr::get( $settings, 'reprocess', 'no' ); + if ( ! $reprocess && ! $is_company_trigger ) { + $enriched_at = $subscriber->getMeta( 'enriched_at', 'custom_field' ); + if ( $enriched_at ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + } + + // --- 3. Resolve provider --- + $provider = $this->resolveProvider( Arr::get( $settings, 'provider', '' ) ); + if ( ! $provider ) { + $this->log( 'error', 'No enrichment provider configured or found.' ); + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + + $min_likelihood = (int) Arr::get( $settings, 'min_likelihood', 6 ); + $data_behavior = Arr::get( $settings, 'data_behavior', 'fill_empty' ); + $company_handling = Arr::get( $settings, 'company_handling', 'create_or_update' ); + $person_result = null; + $company_result = null; + $person_success = false; + $company_success = false; + + // --- 4. Person enrichment --- + if ( in_array( $scope, [ 'both', 'person' ], true ) && $subscriber->email ) { + $person_result = $this->enrichPerson( + $provider, + $subscriber, + $min_likelihood, + $data_behavior, + $funnel_subscriber_id, + $sequence + ); + + $person_success = $person_result instanceof PersonResult; + } + + // --- 5. Company enrichment --- + if ( in_array( $scope, [ 'both', 'company' ], true ) ) { + $company_result = $this->enrichCompany( + $provider, + $subscriber, + $person_result, + $is_company_trigger, + $min_likelihood, + $data_behavior, + $company_handling, + $funnel_subscriber_id, + $sequence + ); + + $company_success = $company_result instanceof CompanyResult; + } + + // --- 6. Apply tags --- + $refreshed = Subscriber::where( 'id', $subscriber->id )->first(); + + if ( $person_success || $company_success ) { + $success_tags = Arr::get( $settings, 'tag_on_success', [] ); + if ( $success_tags && $refreshed ) { + $refreshed->attachTags( $success_tags ); + } + } elseif ( ! $person_success && ! $company_success ) { + $no_match_tags = Arr::get( $settings, 'tag_on_no_match', [] ); + if ( $no_match_tags && $refreshed ) { + $refreshed->attachTags( $no_match_tags ); + } + } + + // --- 7. Fire hooks --- + if ( $refreshed ) { + do_action( 'custom_crm/contact_enriched', $refreshed, $person_result, $company_result ); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Run person enrichment and apply results to the subscriber. + * + * @return PersonResult|EnrichmentError|null + */ + private function enrichPerson( + EnrichmentProvider $provider, + Subscriber $subscriber, + int $min_likelihood, + string $data_behavior, + int $funnel_subscriber_id, + $sequence + ) { + EnrichmentFields::ensureFieldsExist(); + + $params = [ + 'email' => $subscriber->email, + 'first_name' => $subscriber->first_name ?? '', + 'last_name' => $subscriber->last_name ?? '', + 'min_likelihood' => $min_likelihood, + ]; + + $result = $provider->enrichPerson( $params ); + + if ( $result instanceof EnrichmentError ) { + $this->handleError( $result, $subscriber, $funnel_subscriber_id, $sequence, 'person' ); + return $result; + } + + if ( $result->likelihood && $result->likelihood < $min_likelihood ) { + $this->log( 'info', "Person enrichment below threshold ({$result->likelihood} < {$min_likelihood}) for {$subscriber->email}" ); + return null; + } + + // Map native subscriber fields. + $subscriber_fields = $result->toSubscriberFields(); + + if ( 'fill_empty' === $data_behavior ) { + $subscriber_fields = $this->filterEmptyOnly( $subscriber, $subscriber_fields ); + } + + if ( $subscriber_fields ) { + $subscriber->fill( $subscriber_fields ); + $dirty = $subscriber->getDirty(); + if ( $dirty ) { + $subscriber->save(); + do_action( 'fluent_crm/contact_updated', $subscriber, $dirty ); + } + } + + // Map custom fields. + $custom_fields = $result->toCustomFields(); + $custom_fields['enriched_at'] = current_time( 'mysql' ); + $custom_fields['enrichment_provider'] = $provider->getSlug(); + + if ( 'fill_empty' === $data_behavior ) { + $custom_fields = $this->filterEmptyCustomFieldsOnly( $subscriber, $custom_fields ); + // Always update these metadata fields regardless of behavior. + $custom_fields['enriched_at'] = current_time( 'mysql' ); + $custom_fields['enrichment_provider'] = $provider->getSlug(); + } + + if ( $custom_fields ) { + $subscriber->syncCustomFieldValues( $custom_fields, false ); + } + + $this->log( 'info', "Person enrichment successful for {$subscriber->email} (likelihood: {$result->likelihood})" ); + + return $result; + } + + /** + * Run company enrichment and apply results. + * + * @return CompanyResult|EnrichmentError|null + */ + private function enrichCompany( + EnrichmentProvider $provider, + Subscriber $subscriber, + $person_result, + bool $is_company_trigger, + int $min_likelihood, + string $data_behavior, + string $company_handling, + int $funnel_subscriber_id, + $sequence + ) { + // Determine company identifier. + $company_website = null; + $company_name = null; + + if ( $is_company_trigger && $subscriber->company_id ) { + $existing_company = Company::find( $subscriber->company_id ); + if ( $existing_company ) { + $company_website = $existing_company->website; + $company_name = $existing_company->name; + } + } + + if ( ! $company_website && $subscriber->email ) { + $domain = FreeEmailDetector::extractDomain( $subscriber->email ); + if ( $domain && ! FreeEmailDetector::isFreeEmail( $subscriber->email ) ) { + $company_website = $domain; + } + } + + // Fallback to person enrichment result. + if ( ! $company_website && $person_result instanceof PersonResult ) { + $company_website = $person_result->job_company_website; + $company_name = $person_result->job_company_name; + } + + if ( ! $company_website && ! $company_name ) { + $this->log( 'info', "No company identifier found for {$subscriber->email}, skipping company enrichment." ); + return null; + } + + $params = array_filter( [ + 'website' => $company_website, + 'name' => $company_name, + 'min_likelihood' => $min_likelihood, + ] ); + + $result = $provider->enrichCompany( $params ); + + if ( $result instanceof EnrichmentError ) { + $this->handleError( $result, $subscriber, $funnel_subscriber_id, $sequence, 'company' ); + return $result; + } + + // Determine match confidence. + $email_domain = $subscriber->email ? FreeEmailDetector::extractDomain( $subscriber->email ) : ''; + $result_domain = $this->normalizeDomain( $result->website ?? '' ); + $match_type = ( $email_domain && $result_domain && $email_domain === $result_domain ) ? 'confirmed' : 'inferred'; + + // Handle company based on setting. + if ( 'none' === $company_handling ) { + $this->storeCompanyInCustomFields( $subscriber, $result, $match_type, $provider->getSlug(), $data_behavior ); + } elseif ( 'update_existing' === $company_handling ) { + if ( $subscriber->company_id ) { + $company = Company::find( $subscriber->company_id ); + if ( $company ) { + $this->updateCompanyFromResult( $company, $result, $match_type, $provider->getSlug(), $data_behavior ); + } + } + } else { + // create_or_update. + $company = $this->findOrCreateCompany( $result, $match_type, $provider->getSlug(), $data_behavior ); + if ( $company && ! $is_company_trigger && $subscriber->email ) { + $subscriber->company_id = $company->id; + $subscriber->save(); + $subscriber->attachCompanies( [ $company->id ] ); + } + } + + $this->log( 'info', "Company enrichment successful: {$result->name} ({$match_type})" ); + + do_action( 'custom_crm/company_enriched', $company ?? null, $result ); + + return $result; + } + + /** + * Find an existing company by website domain or name, or create a new one. + * + * @return Company|null + */ + private function findOrCreateCompany( + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): ?Company { + $company = null; + + // Try to find by website domain. + if ( $result->website ) { + $normalized = $this->normalizeDomain( $result->website ); + $company = Company::where( 'website', 'LIKE', "%{$normalized}%" )->first(); + } + + // Fallback: find by exact name. + if ( ! $company && $result->name ) { + $company = Company::where( 'name', $result->name )->first(); + } + + if ( $company ) { + $this->updateCompanyFromResult( $company, $result, $match_type, $provider_slug, $data_behavior ); + return $company; + } + + // Create new company. + $fields = $result->toCompanyFields(); + $fields['meta'] = $this->buildCompanyMeta( $result, $match_type, $provider_slug ); + + if ( empty( $fields['name'] ) ) { + return null; + } + + return Company::create( $fields ); + } + + /** + * Update an existing company from enrichment results. + */ + private function updateCompanyFromResult( + Company $company, + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): void { + $fields = $result->toCompanyFields(); + + if ( 'fill_empty' === $data_behavior ) { + $fields = $this->filterEmptyCompanyFields( $company, $fields ); + } + + if ( $fields ) { + $company->fill( $fields ); + $company->save(); + } + + // Always update meta. + $meta = $company->meta; + $enrichment_meta = $this->buildCompanyMeta( $result, $match_type, $provider_slug ); + $company->meta = array_merge( $meta, $enrichment_meta ); + $company->save(); + } + + /** + * Build the enrichment portion of company meta. + * + * @return array + */ + private function buildCompanyMeta( CompanyResult $result, string $match_type, string $provider_slug ): array { + return array_merge( + $result->toCompanyMeta(), + [ + 'enrichment_company_match' => $match_type, + 'enrichment_provider' => $provider_slug, + 'enriched_at' => current_time( 'mysql' ), + ] + ); + } + + /** + * Store company data in contact custom fields (when company_handling = 'none'). + */ + private function storeCompanyInCustomFields( + Subscriber $subscriber, + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): void { + EnrichmentFields::ensureFieldsExist(); + + $custom = [ + 'enrichment_company_name' => $result->name, + 'enrichment_industry' => $result->industry, + 'enrichment_company_match' => $match_type, + ]; + + $custom = array_filter( $custom, static fn( $v ) => null !== $v ); + + if ( 'fill_empty' === $data_behavior ) { + $custom = $this->filterEmptyCustomFieldsOnly( $subscriber, $custom ); + } + + if ( $custom ) { + $subscriber->syncCustomFieldValues( $custom, false ); + } + } + + /** + * Handle an enrichment error: log it and optionally mark the sequence. + */ + private function handleError( + EnrichmentError $error, + Subscriber $subscriber, + int $funnel_subscriber_id, + $sequence, + string $context + ): void { + $email = $subscriber->email ?? '(unknown)'; + + $log_level = in_array( $error->code, [ EnrichmentError::AUTH_FAILED, EnrichmentError::QUOTA_EXCEEDED, EnrichmentError::PROVIDER_ERROR ], true ) + ? 'error' + : 'info'; + + $this->log( $log_level, "{$context} enrichment error for {$email}: [{$error->code}] {$error->message}" ); + + // For fatal errors, skip the sequence. + if ( in_array( $error->code, [ EnrichmentError::AUTH_FAILED, EnrichmentError::INVALID_INPUT, EnrichmentError::QUOTA_EXCEEDED ], true ) ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + } + } + + /** + * Filter subscriber fields to only those that are currently empty. + * + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Fields to filter. + * + * @return array + */ + private function filterEmptyOnly( Subscriber $subscriber, array $fields ): array { + return array_filter( + $fields, + static fn( $value, $key ) => empty( $subscriber->$key ), + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Filter custom fields to only those that are currently empty on the subscriber. + * + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Custom field slug => value. + * + * @return array + */ + private function filterEmptyCustomFieldsOnly( Subscriber $subscriber, array $fields ): array { + $slugs = array_keys( $fields ); + $existing_values = $subscriber->getCustomFieldValues( $slugs ); + + return array_filter( + $fields, + static function ( $value, $key ) use ( $existing_values ) { + $existing = $existing_values[ $key ] ?? null; + return empty( $existing ); + }, + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Filter company fields to only those that are currently empty. + * + * @param Company $company Company model. + * @param array $fields Fields to filter. + * + * @return array + */ + private function filterEmptyCompanyFields( Company $company, array $fields ): array { + return array_filter( + $fields, + static fn( $value, $key ) => empty( $company->$key ), + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Detect if this is a company-triggered funnel. + */ + private function isCompanyTriggered( Subscriber $subscriber, $sequence ): bool { + // Check funnel trigger name if available. + if ( isset( $sequence->funnel ) && isset( $sequence->funnel->trigger_name ) ) { + $trigger = $sequence->funnel->trigger_name; + if ( str_contains( $trigger, 'company' ) ) { + return true; + } + } + + return false; + } + + /** + * Normalize a domain for comparison (strip protocol, www, trailing slash). + */ + private function normalizeDomain( string $url ): string { + $domain = strtolower( $url ); + $domain = preg_replace( '#^https?://#', '', $domain ); + $domain = preg_replace( '#^www\.#', '', $domain ); + $domain = rtrim( $domain, '/' ); + + return $domain; + } + + /** + * Get provider options for the dropdown. + * + * @return array + */ + private function getProviderOptions(): array { + $providers = $this->getRegisteredProviders(); + $options = []; + + foreach ( $providers as $provider ) { + $settings = EnrichmentSettings::getProviderSettings( $provider->getSlug() ); + if ( ! empty( $settings['api_key'] ) ) { + $options[] = [ + 'id' => $provider->getSlug(), + 'title' => $provider->getName(), + ]; + } + } + + if ( empty( $options ) ) { + $options[] = [ + 'id' => '', + 'title' => __( '-- No providers configured --', 'fluent-crm-custom-features' ), + ]; + } + + return $options; + } + + /** + * Resolve a provider instance by slug. + */ + private function resolveProvider( string $slug ): ?EnrichmentProvider { + if ( '' === $slug ) { + $slug = EnrichmentSettings::getActiveProvider(); + } + + $providers = $this->getRegisteredProviders(); + + foreach ( $providers as $provider ) { + if ( $provider->getSlug() === $slug ) { + $settings = EnrichmentSettings::getProviderSettings( $slug ); + if ( ! empty( $settings['api_key'] ) ) { + return $provider; + } + } + } + + return null; + } + + /** + * Get all registered providers. + * + * @return EnrichmentProvider[] + */ + private function getRegisteredProviders(): array { + if ( $this->providers ) { + return $this->providers; + } + + $providers = [ + new PDLProvider(), + ]; + + /** + * Register additional enrichment providers. + * + * @param EnrichmentProvider[] $providers Array of provider instances. + */ + $this->providers = apply_filters( 'custom_crm/enrichment_providers', $providers ); + + return $this->providers; + } + + /** + * Log a message to FluentCRM's internal logger. + * + * @param string $level 'info', 'error', 'warning'. + * @param string $message Log message. + */ + private function log( string $level, string $message ): void { + if ( function_exists( 'fluentCrmLog' ) ) { + fluentCrmLog( $message ); + } + + if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "[CustomCRM Enrichment][{$level}] {$message}" ); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add classes/Actions/EnrichContactAction.php +git commit -m "feat(enrichment): add EnrichContactAction automation block" +``` + +--- + +### Task 10: Register the Action in the Plugin Bootstrap + +**Files:** +- Modify: `fluent-crm-custom-features.php` (add registration line in the `init` callback) + +- [ ] **Step 1: Read the current bootstrap to confirm the exact insertion point** + +The file already has an `add_action('init', function() { ... })` block that registers other actions. Add the new action registration inside this block. + +- [ ] **Step 2: Add the registration line** + +Add the following line inside the existing `init` callback, after the other action registrations: + +```php +( new \CustomCRM\Actions\EnrichContactAction() ); +``` + +This should go after the existing `( new \CustomCRM\Webhooks() );` line (or wherever the last action registration is). + +- [ ] **Step 3: Commit** + +```bash +git add fluent-crm-custom-features.php +git commit -m "feat(enrichment): register EnrichContactAction in plugin bootstrap" +``` + +--- + +### Task 11: Verify Autoloading and Lint + +- [ ] **Step 1: Verify all new classes are autoloadable** + +The plugin uses a custom PSR-4 autoloader that maps `CustomCRM\` to `classes/`. Verify the namespace paths are correct: + +```bash +cd /Users/zackkatz/Local/dev/app/public/wp-content/plugins/fluent-crm-custom-features +php -r " +spl_autoload_register(function(\$class) { + \$prefix = 'CustomCRM\\\\'; + if (strncmp(\$prefix, \$class, strlen(\$prefix)) !== 0) return; + \$relative = substr(\$class, strlen(\$prefix)); + \$file = __DIR__ . '/classes/' . str_replace('\\\\', '/', \$relative) . '.php'; + echo \$class . ' => ' . \$file . ' => ' . (file_exists(\$file) ? 'OK' : 'MISSING') . PHP_EOL; +}); +\$classes = [ + 'CustomCRM\Enrichment\EnrichmentError', + 'CustomCRM\Enrichment\PersonResult', + 'CustomCRM\Enrichment\CompanyResult', + 'CustomCRM\Enrichment\EnrichmentProvider', + 'CustomCRM\Enrichment\FreeEmailDetector', + 'CustomCRM\Enrichment\EnrichmentSettings', + 'CustomCRM\Enrichment\EnrichmentFields', + 'CustomCRM\Enrichment\Providers\PDLProvider', + 'CustomCRM\Actions\EnrichContactAction', +]; +foreach (\$classes as \$c) { new \$c; } +" +``` + +Expected: All classes resolve to existing files and show "OK". + +- [ ] **Step 2: Run PHPCS lint** + +```bash +composer lint 2>&1 | head -50 +``` + +Fix any lint errors in the new files. + +- [ ] **Step 3: Run PHPStan** + +```bash +composer phpstan 2>&1 | head -80 +``` + +Fix any static analysis errors in the new files. + +- [ ] **Step 4: Commit any lint/phpstan fixes** + +```bash +git add -A +git commit -m "fix(enrichment): address lint and static analysis findings" +``` + +--- + +### Task 12: Final Commit and Summary + +- [ ] **Step 1: Verify all files are committed** + +```bash +git status +git log --oneline -12 +``` + +Expected: Clean working tree, all enrichment commits visible in the log. + +- [ ] **Step 2: Tag completion** + +No tag needed — this is on a feature branch. The commits are ready for PR review. From 3d50991325dedb28644d6c10f4d10220be147522 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 16:51:39 -0400 Subject: [PATCH 19/30] feat(enrichment): add EnrichmentError, PersonResult, and CompanyResult DTOs Normalized data transfer objects for enrichment providers: error codes with retryable flag, person fields mapping to subscriber/custom columns, and company fields mapping to native Company columns and meta JSON. --- classes/Enrichment/CompanyResult.php | 95 ++++++++++++++++++++++++ classes/Enrichment/EnrichmentError.php | 47 ++++++++++++ classes/Enrichment/PersonResult.php | 99 ++++++++++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 classes/Enrichment/CompanyResult.php create mode 100644 classes/Enrichment/EnrichmentError.php create mode 100644 classes/Enrichment/PersonResult.php diff --git a/classes/Enrichment/CompanyResult.php b/classes/Enrichment/CompanyResult.php new file mode 100644 index 0000000..fcdd4b8 --- /dev/null +++ b/classes/Enrichment/CompanyResult.php @@ -0,0 +1,95 @@ + Only non-null values. + */ + public function toCompanyFields(): array { + $map = [ + 'name' => $this->name, + 'industry' => $this->industry, + 'type' => $this->type, + 'website' => $this->website, + 'email' => $this->email, + 'phone' => $this->phone, + 'address_line_1' => $this->address_line_1, + 'city' => $this->city, + 'state' => $this->state, + 'country' => $this->country, + 'postal_code' => $this->postal_code, + 'employees_number' => $this->employees_number, + 'description' => $this->description, + 'logo' => $this->logo, + 'linkedin_url' => $this->linkedin_url, + 'facebook_url' => $this->facebook_url, + 'twitter_url' => $this->twitter_url, + 'date_of_start' => $this->date_of_start, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } + + /** + * Return extra fields for the Company meta JSON column. + * + * @return array Only non-null values. + */ + public function toCompanyMeta(): array { + $map = [ + 'funding_raised' => $this->funding_raised, + 'funding_stage' => $this->funding_stage, + 'inferred_revenue' => $this->inferred_revenue, + 'employee_growth_rate_12mo' => $this->employee_growth_rate_12mo, + 'ticker' => $this->ticker, + 'naics_codes' => $this->naics_codes, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } +} diff --git a/classes/Enrichment/EnrichmentError.php b/classes/Enrichment/EnrichmentError.php new file mode 100644 index 0000000..3277174 --- /dev/null +++ b/classes/Enrichment/EnrichmentError.php @@ -0,0 +1,47 @@ +code = $code; + $this->message = $message; + $this->http_status = $http_status; + $this->retryable = $retryable; + } +} diff --git a/classes/Enrichment/PersonResult.php b/classes/Enrichment/PersonResult.php new file mode 100644 index 0000000..9c55052 --- /dev/null +++ b/classes/Enrichment/PersonResult.php @@ -0,0 +1,99 @@ + Only non-null values. + */ + public function toSubscriberFields(): array { + $map = [ + 'first_name' => $this->first_name, + 'last_name' => $this->last_name, + 'phone' => $this->phone, + 'city' => $this->city, + 'state' => $this->state, + 'country' => $this->country, + 'postal_code' => $this->postal_code, + 'address_line_1' => $this->address_line_1, + 'date_of_birth' => $this->date_of_birth, + 'timezone' => $this->timezone, + 'latitude' => $this->latitude, + 'longitude' => $this->longitude, + 'avatar' => $this->avatar, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } + + /** + * Return fields that map to FluentCRM custom fields. + * + * @return array Only non-null values, keyed by custom field slug. + */ + public function toCustomFields(): array { + $map = [ + 'enrichment_job_title' => $this->job_title, + 'enrichment_job_role' => $this->job_role, + 'enrichment_job_level' => $this->job_level, + 'enrichment_company_name' => $this->job_company_name, + 'enrichment_linkedin_url' => $this->linkedin_url, + 'enrichment_twitter_url' => $this->twitter_url, + 'enrichment_facebook_url' => $this->facebook_url, + 'enrichment_github_url' => $this->github_url, + 'enrichment_sex' => $this->sex, + 'enrichment_pronouns' => $this->pronouns, + 'enrichment_inferred_salary' => $this->inferred_salary, + 'enrichment_industry' => $this->industry, + 'enrichment_likelihood' => $this->likelihood, + ]; + + return array_filter( $map, static fn( $v ) => null !== $v ); + } +} From 3c37435060bcfdeb631533193d19981298047679 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 16:51:43 -0400 Subject: [PATCH 20/30] feat(enrichment): add abstract EnrichmentProvider with httpGet helper Defines the provider contract: getSlug, getName, enrichPerson, enrichCompany, getSettingsFields, validateSettings, and the abstract mapError hook. The concrete httpGet helper wraps wp_remote_get and returns a normalized array or EnrichmentError on failure. --- classes/Enrichment/EnrichmentProvider.php | 118 ++++++++++++++++++++++ 1 file changed, 118 insertions(+) create mode 100644 classes/Enrichment/EnrichmentProvider.php diff --git a/classes/Enrichment/EnrichmentProvider.php b/classes/Enrichment/EnrichmentProvider.php new file mode 100644 index 0000000..1b635b5 --- /dev/null +++ b/classes/Enrichment/EnrichmentProvider.php @@ -0,0 +1,118 @@ + $params Keys like 'email', 'first_name', 'last_name', 'company', etc. + * + * @return PersonResult|EnrichmentError + */ + abstract public function enrichPerson( array $params ); + + /** + * Enrich a company. + * + * @param array $params Keys like 'website', 'name', 'linkedin_url', etc. + * + * @return CompanyResult|EnrichmentError + */ + abstract public function enrichCompany( array $params ); + + /** + * Declare settings fields this provider needs (shown on settings page). + * + * @return array> Keyed by field name. + */ + abstract public function getSettingsFields(): array; + + /** + * Validate saved settings (e.g. test an API key). + * + * @param array $settings The provider's saved settings. + * + * @return true|string True on success, error message string on failure. + */ + abstract public function validateSettings( array $settings ); + + /** + * Map a provider-specific HTTP error to a normalized EnrichmentError. + * + * @param int $http_status HTTP status code. + * @param array $response_body Decoded response body. + * + * @return EnrichmentError + */ + abstract protected function mapError( int $http_status, array $response_body ): EnrichmentError; + + /** + * Make an HTTP GET request via WordPress HTTP API. + * + * @param string $url Full URL with query params. + * @param array $headers Request headers. + * + * @return array{status:int,body:array}|EnrichmentError + */ + protected function httpGet( string $url, array $headers = [] ) { + $response = wp_remote_get( + $url, + [ + 'headers' => $headers, + 'timeout' => 15, + ] + ); + + if ( is_wp_error( $response ) ) { + return new EnrichmentError( + EnrichmentError::NETWORK_ERROR, + $response->get_error_message(), + null, + true + ); + } + + $status = (int) wp_remote_retrieve_response_code( $response ); + $body = json_decode( wp_remote_retrieve_body( $response ), true ); + + if ( ! is_array( $body ) ) { + $body = []; + } + + if ( $status < 200 || $status >= 300 ) { + return $this->mapError( $status, $body ); + } + + return [ + 'status' => $status, + 'body' => $body, + ]; + } +} From 5052a4f130a6a2a3a0db34f9edef16bb5fe60661 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 16:51:49 -0400 Subject: [PATCH 21/30] feat(enrichment): add FreeEmailDetector, EnrichmentSettings, and EnrichmentFields utilities - FreeEmailDetector: static domain check with filterable domain list via custom_crm/free_email_domains hook - EnrichmentSettings: static CRUD over wp_options with per-provider storage, active provider tracking, and base64/wp_encrypt API key encryption - EnrichmentFields: auto-creates 16 FluentCRM custom fields in the Enrichment group; uses a daily transient to avoid repeated DB reads --- classes/Enrichment/EnrichmentFields.php | 151 ++++++++++++++++++++++ classes/Enrichment/EnrichmentSettings.php | 148 +++++++++++++++++++++ classes/Enrichment/FreeEmailDetector.php | 94 ++++++++++++++ 3 files changed, 393 insertions(+) create mode 100644 classes/Enrichment/EnrichmentFields.php create mode 100644 classes/Enrichment/EnrichmentSettings.php create mode 100644 classes/Enrichment/FreeEmailDetector.php diff --git a/classes/Enrichment/EnrichmentFields.php b/classes/Enrichment/EnrichmentFields.php new file mode 100644 index 0000000..0478eef --- /dev/null +++ b/classes/Enrichment/EnrichmentFields.php @@ -0,0 +1,151 @@ +> + */ + public static function getFieldDefinitions(): array { + return [ + [ + 'slug' => 'enrichment_job_title', + 'label' => 'Job Title', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_job_role', + 'label' => 'Job Role', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_job_level', + 'label' => 'Job Level', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_company_name', + 'label' => 'Company Name', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_linkedin_url', + 'label' => 'LinkedIn URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_twitter_url', + 'label' => 'Twitter URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_facebook_url', + 'label' => 'Facebook URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_github_url', + 'label' => 'GitHub URL', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_sex', + 'label' => 'Sex', + 'type' => 'select-one', + 'group' => 'Enrichment', + 'options' => [ 'male', 'female' ], + ], + [ + 'slug' => 'enrichment_pronouns', + 'label' => 'Pronouns', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_inferred_salary', + 'label' => 'Inferred Salary', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_industry', + 'label' => 'Industry', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enriched_at', + 'label' => 'Enriched At', + 'type' => 'date_time', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_provider', + 'label' => 'Enrichment Provider', + 'type' => 'text', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_likelihood', + 'label' => 'Match Likelihood', + 'type' => 'number', + 'group' => 'Enrichment', + ], + [ + 'slug' => 'enrichment_company_match', + 'label' => 'Company Match Type', + 'type' => 'select-one', + 'group' => 'Enrichment', + 'options' => [ 'confirmed', 'inferred' ], + ], + ]; + } +} diff --git a/classes/Enrichment/EnrichmentSettings.php b/classes/Enrichment/EnrichmentSettings.php new file mode 100644 index 0000000..8eec70a --- /dev/null +++ b/classes/Enrichment/EnrichmentSettings.php @@ -0,0 +1,148 @@ +>} + */ + public static function getAll(): array { + $defaults = [ + 'active_provider' => '', + 'providers' => [], + ]; + + $settings = get_option( self::OPTION_KEY, $defaults ); + + return wp_parse_args( $settings, $defaults ); + } + + /** + * Save all settings. + * + * @param array $settings Full settings array. + */ + public static function saveAll( array $settings ): void { + update_option( self::OPTION_KEY, $settings, false ); + } + + /** + * Get settings for a specific provider. + * + * @param string $slug Provider slug. + * + * @return array + */ + public static function getProviderSettings( string $slug ): array { + $all = self::getAll(); + + return $all['providers'][ $slug ] ?? []; + } + + /** + * Save settings for a specific provider. + * + * @param string $slug Provider slug. + * @param array $settings Provider-specific settings. + */ + public static function saveProviderSettings( string $slug, array $settings ): void { + $all = self::getAll(); + $all['providers'][ $slug ] = $settings; + + // Auto-set active provider if none set. + if ( empty( $all['active_provider'] ) ) { + $all['active_provider'] = $slug; + } + + self::saveAll( $all ); + } + + /** + * Get the active provider slug. + * + * @return string + */ + public static function getActiveProvider(): string { + $all = self::getAll(); + + return $all['active_provider'] ?? ''; + } + + /** + * Get the API key for a provider, decrypted. + * + * @param string $slug Provider slug. + * + * @return string API key or empty string. + */ + public static function getApiKey( string $slug ): string { + $settings = self::getProviderSettings( $slug ); + + $encrypted = $settings['api_key'] ?? ''; + + if ( '' === $encrypted ) { + return ''; + } + + return self::decrypt( $encrypted ); + } + + /** + * Encrypt a value for storage. + * + * Uses wp_encrypt() on WP 6.5+, falls back to base64 with site salt. + * + * @param string $value Plain text value. + * + * @return string Encrypted string. + */ + public static function encrypt( string $value ): string { + if ( function_exists( 'wp_encrypt' ) ) { + $result = wp_encrypt( $value ); + if ( ! is_wp_error( $result ) ) { + return $result; + } + } + + // Fallback: base64 with salt prefix for identification. + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + return 'b64:' . base64_encode( $value ); + } + + /** + * Decrypt a stored value. + * + * @param string $encrypted Encrypted string. + * + * @return string Decrypted value. + */ + public static function decrypt( string $encrypted ): string { + if ( str_starts_with( $encrypted, 'b64:' ) ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + return base64_decode( substr( $encrypted, 4 ) ); + } + + if ( function_exists( 'wp_decrypt' ) ) { + $result = wp_decrypt( $encrypted ); + if ( ! is_wp_error( $result ) ) { + return $result; + } + } + + // If we can't decrypt, return as-is (may be plain text from old storage). + return $encrypted; + } +} diff --git a/classes/Enrichment/FreeEmailDetector.php b/classes/Enrichment/FreeEmailDetector.php new file mode 100644 index 0000000..bcb9d57 --- /dev/null +++ b/classes/Enrichment/FreeEmailDetector.php @@ -0,0 +1,94 @@ + Date: Thu, 26 Mar 2026 16:53:38 -0400 Subject: [PATCH 22/30] feat(enrichment): add PDLProvider with person and company enrichment --- classes/Enrichment/Providers/PDLProvider.php | 327 +++++++++++++++++++ 1 file changed, 327 insertions(+) create mode 100644 classes/Enrichment/Providers/PDLProvider.php diff --git a/classes/Enrichment/Providers/PDLProvider.php b/classes/Enrichment/Providers/PDLProvider.php new file mode 100644 index 0000000..f7d229f --- /dev/null +++ b/classes/Enrichment/Providers/PDLProvider.php @@ -0,0 +1,327 @@ + [ + 'label' => __( 'API Key', 'fluent-crm-custom-features' ), + 'type' => 'password', + 'placeholder' => __( 'Your People Data Labs API key', 'fluent-crm-custom-features' ), + 'help' => __( 'Get your key at dashboard.peopledatalabs.com', 'fluent-crm-custom-features' ), + ], + 'api_tier' => [ + 'label' => __( 'Plan Tier', 'fluent-crm-custom-features' ), + 'type' => 'select', + 'options' => [ + 'free' => __( 'Free (100 req/min)', 'fluent-crm-custom-features' ), + 'paid' => __( 'Paid (1,000 req/min)', 'fluent-crm-custom-features' ), + ], + 'default' => 'free', + ], + ]; + } + + /** + * {@inheritDoc} + */ + public function validateSettings( array $settings ) { + $api_key = $settings['api_key'] ?? ''; + + if ( '' === $api_key ) { + return __( 'API key is required.', 'fluent-crm-custom-features' ); + } + + // Make a lightweight test call — search for a known test entity. + $url = add_query_arg( + [ + 'email' => 'sean@peopledatalabs.com', + 'min_likelihood' => 1, + 'data_include' => 'full_name', + 'pretty' => 'false', + ], + self::PERSON_ENDPOINT + ); + + $result = $this->httpGet( + $url, + [ 'X-Api-Key' => $api_key ] + ); + + if ( $result instanceof EnrichmentError ) { + if ( EnrichmentError::AUTH_FAILED === $result->code ) { + return __( 'Invalid API key.', 'fluent-crm-custom-features' ); + } + + return $result->message; + } + + return true; + } + + /** + * {@inheritDoc} + */ + public function enrichPerson( array $params ) { + $api_key = $this->getApiKey(); + + if ( '' === $api_key ) { + return new EnrichmentError( + EnrichmentError::AUTH_FAILED, + 'PDL API key not configured.', + null, + false + ); + } + + $query = array_filter( + [ + 'email' => $params['email'] ?? '', + 'first_name' => $params['first_name'] ?? '', + 'last_name' => $params['last_name'] ?? '', + 'company' => $params['company'] ?? '', + 'location' => $params['location'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 6, + 'include_if_matched' => 'true', + 'titlecase' => 'true', + 'pretty' => 'false', + ], + static fn( $v ) => '' !== $v && null !== $v + ); + + $url = add_query_arg( $query, self::PERSON_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + + if ( $result instanceof EnrichmentError ) { + return $result; + } + + return $this->mapPersonResponse( $result['body'] ); + } + + /** + * {@inheritDoc} + */ + public function enrichCompany( array $params ) { + $api_key = $this->getApiKey(); + + if ( '' === $api_key ) { + return new EnrichmentError( + EnrichmentError::AUTH_FAILED, + 'PDL API key not configured.', + null, + false + ); + } + + $query = array_filter( + [ + 'website' => $params['website'] ?? '', + 'name' => $params['name'] ?? '', + 'profile' => $params['linkedin_url'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 2, + 'include_if_matched' => 'true', + 'titlecase' => 'true', + 'pretty' => 'false', + ], + static fn( $v ) => '' !== $v && null !== $v + ); + + $url = add_query_arg( $query, self::COMPANY_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + + if ( $result instanceof EnrichmentError ) { + return $result; + } + + return $this->mapCompanyResponse( $result['body'] ); + } + + /** + * {@inheritDoc} + */ + protected function mapError( int $http_status, array $response_body ): EnrichmentError { + $message = $response_body['error']['message'] ?? "HTTP {$http_status}"; + + return match ( $http_status ) { + 400 => new EnrichmentError( EnrichmentError::INVALID_INPUT, $message, 400, false ), + 401 => new EnrichmentError( EnrichmentError::AUTH_FAILED, $message, 401, false ), + 402,403 => new EnrichmentError( EnrichmentError::QUOTA_EXCEEDED, $message, $http_status, false ), + 404 => new EnrichmentError( EnrichmentError::NO_MATCH, 'No matching record found.', 404, false ), + 429 => new EnrichmentError( EnrichmentError::RATE_LIMITED, $message, 429, true ), + default => new EnrichmentError( + $http_status >= 500 ? EnrichmentError::PROVIDER_ERROR : EnrichmentError::PROVIDER_ERROR, + $message, + $http_status, + $http_status >= 500 + ), + }; + } + + /** + * Map PDL person response to PersonResult DTO. + * + * @param array $body Decoded PDL response body. + * + * @return PersonResult + */ + private function mapPersonResponse( array $body ): PersonResult { + $data = $body['data'] ?? $body; + $result = new PersonResult(); + + $result->first_name = $data['first_name'] ?? null; + $result->last_name = $data['last_name'] ?? null; + $result->phone = $data['mobile_phone'] ?? ( $data['phone_numbers'][0] ?? null ); + $result->city = $data['location_locality'] ?? null; + $result->state = $data['location_region'] ?? null; + $result->country = $data['location_country'] ?? null; + $result->postal_code = $data['location_postal_code'] ?? null; + $result->address_line_1 = $data['location_street_address'] ?? null; + $result->date_of_birth = $data['birth_date'] ?? null; + $result->linkedin_url = $data['linkedin_url'] ?? null; + $result->twitter_url = $data['twitter_url'] ?? null; + $result->facebook_url = $data['facebook_url'] ?? null; + $result->github_url = $data['github_url'] ?? null; + $result->job_title = $data['job_title'] ?? null; + $result->job_role = $data['job_title_role'] ?? null; + $result->industry = $data['industry'] ?? ( $data['job_company_industry'] ?? null ); + $result->inferred_salary = $data['inferred_salary'] ?? null; + $result->sex = $data['sex'] ?? null; + + // Derive pronouns from sex if available. + if ( $result->sex && null === $result->pronouns ) { + $result->pronouns = match ( strtolower( $result->sex ) ) { + 'male' => 'he/him', + 'female' => 'she/her', + default => null, + }; + } + + // Job level: PDL returns an array, take the first. + $levels = $data['job_title_levels'] ?? []; + $result->job_level = is_array( $levels ) && $levels ? $levels[0] : null; + + // Company data from person response. + $result->job_company_name = $data['job_company_name'] ?? null; + $result->job_company_website = $data['job_company_website'] ?? null; + + // Geo coordinates. + $geo = $data['location_geo'] ?? null; + if ( $geo && is_string( $geo ) && str_contains( $geo, ',' ) ) { + $parts = explode( ',', $geo ); + $result->latitude = (float) trim( $parts[0] ); + $result->longitude = (float) trim( $parts[1] ); + } + + $result->likelihood = $body['likelihood'] ?? null; + + /** @var PersonResult */ + return apply_filters( 'custom_crm/enrichment_person_result', $result, $body ); + } + + /** + * Map PDL company response to CompanyResult DTO. + * + * @param array $body Decoded PDL response body. + * + * @return CompanyResult + */ + private function mapCompanyResponse( array $body ): CompanyResult { + // PDL company responses put fields at root level (not nested under 'data'). + $data = $body; + $result = new CompanyResult(); + + $result->name = $data['display_name'] ?? ( $data['name'] ?? null ); + $result->industry = $data['industry'] ?? null; + $result->type = $data['type'] ?? null; + $result->website = $data['website'] ?? null; + $result->phone = $data['phone'] ?? null; + $result->linkedin_url = $data['linkedin_url'] ?? null; + $result->facebook_url = $data['facebook_url'] ?? null; + $result->twitter_url = $data['twitter_url'] ?? null; + $result->employees_number = $data['employee_count'] ?? null; + $result->description = $data['summary'] ?? null; + $result->logo = $data['logo'] ?? null; + $result->ticker = $data['ticker'] ?? null; + $result->inferred_revenue = $data['inferred_revenue'] ?? null; + $result->funding_raised = isset( $data['total_funding_raised'] ) ? (int) $data['total_funding_raised'] : null; + $result->funding_stage = $data['latest_funding_stage'] ?? null; + + // Founded year -> date_of_start. + if ( ! empty( $data['founded'] ) ) { + $result->date_of_start = (string) $data['founded']; + } + + // Location fields. + $location = $data['location'] ?? []; + if ( is_array( $location ) ) { + $result->address_line_1 = $location['street_address'] ?? null; + $result->city = $location['locality'] ?? null; + $result->state = $location['region'] ?? null; + $result->country = $location['country'] ?? null; + $result->postal_code = $location['postal_code'] ?? null; + } + + // Employee growth rate. + $growth = $data['employee_growth_rate'] ?? []; + if ( is_array( $growth ) && isset( $growth['12_month'] ) ) { + $result->employee_growth_rate_12mo = (float) $growth['12_month']; + } + + // NAICS codes. + $result->naics_codes = $data['naics'] ?? null; + + $result->likelihood = $body['likelihood'] ?? null; + + /** @var CompanyResult */ + return apply_filters( 'custom_crm/enrichment_company_result', $result, $body ); + } + + /** + * Get the decrypted API key for this provider. + * + * @return string + */ + private function getApiKey(): string { + return EnrichmentSettings::getApiKey( $this->getSlug() ); + } +} From 8340b061ed93a85c083a517d64e7e649dd34549e Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 16:54:17 -0400 Subject: [PATCH 23/30] fix(enrichment): replace nonexistent wp_encrypt/wp_decrypt with OpenSSL Use aes-256-cbc with WordPress AUTH_KEY as the encryption key. Falls back to base64 obfuscation if OpenSSL is unavailable. --- classes/Enrichment/EnrichmentSettings.php | 55 ++++++++++++++++------- 1 file changed, 38 insertions(+), 17 deletions(-) diff --git a/classes/Enrichment/EnrichmentSettings.php b/classes/Enrichment/EnrichmentSettings.php index 8eec70a..3d3ff3b 100644 --- a/classes/Enrichment/EnrichmentSettings.php +++ b/classes/Enrichment/EnrichmentSettings.php @@ -101,23 +101,26 @@ public static function getApiKey( string $slug ): string { } /** - * Encrypt a value for storage. - * - * Uses wp_encrypt() on WP 6.5+, falls back to base64 with site salt. + * Encrypt a value for storage using OpenSSL with WordPress AUTH_KEY as the key. * * @param string $value Plain text value. * - * @return string Encrypted string. + * @return string Encrypted string (base64-encoded with 'enc:' prefix). */ public static function encrypt( string $value ): string { - if ( function_exists( 'wp_encrypt' ) ) { - $result = wp_encrypt( $value ); - if ( ! is_wp_error( $result ) ) { - return $result; + $key = self::getEncryptionKey(); + + if ( function_exists( 'openssl_encrypt' ) ) { + $iv = openssl_random_pseudo_bytes( 16 ); + $encrypted = openssl_encrypt( $value, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); + + if ( false !== $encrypted ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode + return 'enc:' . base64_encode( $iv . $encrypted ); } } - // Fallback: base64 with salt prefix for identification. + // Fallback if OpenSSL unavailable: base64 obfuscation only. // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode return 'b64:' . base64_encode( $value ); } @@ -130,19 +133,37 @@ public static function encrypt( string $value ): string { * @return string Decrypted value. */ public static function decrypt( string $encrypted ): string { - if ( str_starts_with( $encrypted, 'b64:' ) ) { + if ( str_starts_with( $encrypted, 'enc:' ) && function_exists( 'openssl_decrypt' ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode - return base64_decode( substr( $encrypted, 4 ) ); - } + $raw = base64_decode( substr( $encrypted, 4 ) ); + $iv = substr( $raw, 0, 16 ); + $data = substr( $raw, 16 ); + $key = self::getEncryptionKey(); + + $decrypted = openssl_decrypt( $data, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); - if ( function_exists( 'wp_decrypt' ) ) { - $result = wp_decrypt( $encrypted ); - if ( ! is_wp_error( $result ) ) { - return $result; + if ( false !== $decrypted ) { + return $decrypted; } } - // If we can't decrypt, return as-is (may be plain text from old storage). + if ( str_starts_with( $encrypted, 'b64:' ) ) { + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode + return base64_decode( substr( $encrypted, 4 ) ); + } + + // Plain text from old storage. return $encrypted; } + + /** + * Derive an encryption key from WordPress salts. + * + * @return string 32-byte key. + */ + private static function getEncryptionKey(): string { + $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'custom-crm-fallback-key'; + + return hash( 'sha256', $salt, true ); + } } From a85fc6a28c9623ec87ec26e0e8aacdbe99d8d751 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 16:58:04 -0400 Subject: [PATCH 24/30] feat(enrichment): add EnrichContactAction automation block Orchestration layer that registers as a FluentCRM automation action, provides settings UI, and coordinates person/company enrichment flow with configurable scope, data behavior, and company handling modes. --- classes/Actions/EnrichContactAction.php | 806 ++++++++++++++++++++++++ 1 file changed, 806 insertions(+) create mode 100644 classes/Actions/EnrichContactAction.php diff --git a/classes/Actions/EnrichContactAction.php b/classes/Actions/EnrichContactAction.php new file mode 100644 index 0000000..d0c3af3 --- /dev/null +++ b/classes/Actions/EnrichContactAction.php @@ -0,0 +1,806 @@ + + */ + private array $providers = []; + + public function __construct() { + // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + $this->actionName = 'enrich_contact'; + $this->priority = 30; + parent::__construct(); + } + + /** + * Get block definition shown in the automation editor sidebar. + * + * @return array + */ + public function getBlock() { + return [ + 'category' => __( 'CRM', 'fluent-crm-custom-features' ), + 'title' => __( 'Enrich Contact', 'fluent-crm-custom-features' ), + 'description' => __( 'Enrich contact and company data using an external provider', 'fluent-crm-custom-features' ), + 'icon' => 'fc-icon-wp_user_meta', + 'settings' => [ + 'provider' => EnrichmentSettings::getActiveProvider(), + 'enrichment_scope' => 'both', + 'min_likelihood' => 6, + 'data_behavior' => 'fill_empty', + 'reprocess' => 'no', + 'company_handling' => 'create_or_update', + 'tag_on_success' => [], + 'tag_on_no_match' => [], + ], + ]; + } + + /** + * Get block field definitions for the settings editor. + * + * @return array + */ + public function getBlockFields() { + return [ + 'title' => __( 'Enrich Contact', 'fluent-crm-custom-features' ), + 'sub_title' => __( 'Enrich contact and company data using an external provider', 'fluent-crm-custom-features' ), + 'fields' => [ + 'provider' => [ + 'type' => 'select', + 'label' => __( 'Enrichment Provider', 'fluent-crm-custom-features' ), + 'options' => $this->getProviderOptions(), + 'inline_help' => __( 'Configure providers in FluentCRM Custom Features settings.', 'fluent-crm-custom-features' ), + ], + 'enrichment_scope' => [ + 'type' => 'radio', + 'label' => __( 'Enrichment Scope', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'both', + 'title' => __( 'Person + Company', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'person', + 'title' => __( 'Person Only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'company', + 'title' => __( 'Company Only', 'fluent-crm-custom-features' ), + ], + ], + ], + 'min_likelihood' => [ + 'type' => 'input-number', + 'label' => __( 'Minimum Likelihood Score (1-10)', 'fluent-crm-custom-features' ), + 'wrapper_class' => 'fc_2col_inline pad-r-20', + 'inline_help' => __( 'Higher = more accurate but fewer matches. Recommended: 6.', 'fluent-crm-custom-features' ), + ], + 'data_behavior' => [ + 'type' => 'radio', + 'label' => __( 'Data Behavior', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'fill_empty', + 'title' => __( 'Fill empty fields only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'overwrite', + 'title' => __( 'Overwrite all fields', 'fluent-crm-custom-features' ), + ], + ], + ], + 'reprocess' => [ + 'type' => 'yes_no_check', + 'check_label' => __( 'Re-enrich contacts that have already been enriched', 'fluent-crm-custom-features' ), + ], + 'company_handling' => [ + 'type' => 'radio', + 'label' => __( 'Company Handling', 'fluent-crm-custom-features' ), + 'options' => [ + [ + 'id' => 'create_or_update', + 'title' => __( 'Create or update company', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'update_existing', + 'title' => __( 'Update existing company only', 'fluent-crm-custom-features' ), + ], + [ + 'id' => 'none', + 'title' => __( "Don't touch companies", 'fluent-crm-custom-features' ), + ], + ], + 'dependency' => [ + 'depends_on' => 'enrichment_scope', + 'value' => 'person', + 'operator' => '!=', + ], + ], + 'tag_on_success' => [ + 'type' => 'option_selectors', + 'option_key' => 'tags', + 'is_multiple' => true, + 'label' => __( 'Tag on Successful Enrichment', 'fluent-crm-custom-features' ), + 'placeholder' => __( 'Select Tags (optional)', 'fluent-crm-custom-features' ), + ], + 'tag_on_no_match' => [ + 'type' => 'option_selectors', + 'option_key' => 'tags', + 'is_multiple' => true, + 'label' => __( 'Tag on No Match', 'fluent-crm-custom-features' ), + 'placeholder' => __( 'Select Tags (optional)', 'fluent-crm-custom-features' ), + ], + ], + ]; + } + + /** + * Execute the enrichment action. + * + * @param \FluentCrm\App\Models\Subscriber $subscriber The subscriber. + * @param \FluentCrm\App\Models\FunnelSequence $sequence The funnel sequence. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $funnel_metric Funnel metric. + */ + public function handle( $subscriber, $sequence, $funnel_subscriber_id, $funnel_metric ) { + $settings = $sequence->settings; + $scope = Arr::get( $settings, 'enrichment_scope', 'both' ); + + // --- 1. Detect context --- + $is_company_trigger = $this->isCompanyTriggered( $subscriber, $sequence ); + + if ( $is_company_trigger && 'person' === $scope ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + + if ( $is_company_trigger ) { + $scope = 'company'; + } + + // --- 2. Check skip conditions --- + $reprocess = 'yes' === Arr::get( $settings, 'reprocess', 'no' ); + if ( ! $reprocess && ! $is_company_trigger ) { + $enriched_at = $subscriber->getMeta( 'enriched_at', 'custom_field' ); + if ( $enriched_at ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + } + + // --- 3. Resolve provider --- + $provider = $this->resolveProvider( Arr::get( $settings, 'provider', '' ) ); + if ( ! $provider ) { + $this->log( 'error', 'No enrichment provider configured or found.' ); + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + return; + } + + $min_likelihood = (int) Arr::get( $settings, 'min_likelihood', 6 ); + $data_behavior = Arr::get( $settings, 'data_behavior', 'fill_empty' ); + $company_handling = Arr::get( $settings, 'company_handling', 'create_or_update' ); + $person_result = null; + $company_result = null; + $person_success = false; + $company_success = false; + + // --- 4. Person enrichment --- + if ( in_array( $scope, [ 'both', 'person' ], true ) && $subscriber->email ) { + $person_result = $this->enrichPerson( + $provider, + $subscriber, + $min_likelihood, + $data_behavior, + $funnel_subscriber_id, + $sequence + ); + + $person_success = $person_result instanceof PersonResult; + } + + // --- 5. Company enrichment --- + if ( in_array( $scope, [ 'both', 'company' ], true ) ) { + $company_result = $this->enrichCompany( + $provider, + $subscriber, + $person_result, + $is_company_trigger, + $min_likelihood, + $data_behavior, + $company_handling, + $funnel_subscriber_id, + $sequence + ); + + $company_success = $company_result instanceof CompanyResult; + } + + // --- 6. Apply tags --- + $refreshed = Subscriber::where( 'id', $subscriber->id )->first(); + + if ( $person_success || $company_success ) { + $success_tags = Arr::get( $settings, 'tag_on_success', [] ); + if ( $success_tags && $refreshed ) { + $refreshed->attachTags( $success_tags ); + } + } elseif ( ! $person_success && ! $company_success ) { + $no_match_tags = Arr::get( $settings, 'tag_on_no_match', [] ); + if ( $no_match_tags && $refreshed ) { + $refreshed->attachTags( $no_match_tags ); + } + } + + // --- 7. Fire hooks --- + if ( $refreshed ) { + do_action( 'custom_crm/contact_enriched', $refreshed, $person_result, $company_result ); + } + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Run person enrichment and apply results to the subscriber. + * + * @param EnrichmentProvider $provider The provider. + * @param Subscriber $subscriber The subscriber. + * @param int $min_likelihood Minimum likelihood threshold. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $sequence The funnel sequence. + * + * @return PersonResult|EnrichmentError|null + */ + private function enrichPerson( + EnrichmentProvider $provider, + Subscriber $subscriber, + int $min_likelihood, + string $data_behavior, + int $funnel_subscriber_id, + $sequence + ) { + EnrichmentFields::ensureFieldsExist(); + + $params = [ + 'email' => $subscriber->email, + 'first_name' => $subscriber->first_name ?? '', + 'last_name' => $subscriber->last_name ?? '', + 'min_likelihood' => $min_likelihood, + ]; + + $result = $provider->enrichPerson( $params ); + + if ( $result instanceof EnrichmentError ) { + $this->handleError( $result, $subscriber, $funnel_subscriber_id, $sequence, 'person' ); + return $result; + } + + if ( $result->likelihood && $result->likelihood < $min_likelihood ) { + $this->log( 'info', "Person enrichment below threshold ({$result->likelihood} < {$min_likelihood}) for {$subscriber->email}" ); + return null; + } + + // Map native subscriber fields. + $subscriber_fields = $result->toSubscriberFields(); + + if ( 'fill_empty' === $data_behavior ) { + $subscriber_fields = $this->filterEmptyOnly( $subscriber, $subscriber_fields ); + } + + if ( $subscriber_fields ) { + $subscriber->fill( $subscriber_fields ); + $dirty = $subscriber->getDirty(); + if ( $dirty ) { + $subscriber->save(); + do_action( 'fluent_crm/contact_updated', $subscriber, $dirty ); + } + } + + // Map custom fields. + $custom_fields = $result->toCustomFields(); + $custom_fields['enriched_at'] = current_time( 'mysql' ); + $custom_fields['enrichment_provider'] = $provider->getSlug(); + + if ( 'fill_empty' === $data_behavior ) { + $custom_fields = $this->filterEmptyCustomFieldsOnly( $subscriber, $custom_fields ); + // Always update these metadata fields regardless of behavior. + $custom_fields['enriched_at'] = current_time( 'mysql' ); + $custom_fields['enrichment_provider'] = $provider->getSlug(); + } + + if ( $custom_fields ) { + $subscriber->syncCustomFieldValues( $custom_fields, false ); + } + + $this->log( 'info', "Person enrichment successful for {$subscriber->email} (likelihood: {$result->likelihood})" ); + + return $result; + } + + /** + * Run company enrichment and apply results. + * + * @param EnrichmentProvider $provider The provider. + * @param Subscriber $subscriber The subscriber. + * @param mixed $person_result PersonResult or null. + * @param bool $is_company_trigger Whether this is a company-triggered funnel. + * @param int $min_likelihood Minimum likelihood threshold. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + * @param string $company_handling Company handling mode. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $sequence The funnel sequence. + * + * @return CompanyResult|EnrichmentError|null + */ + private function enrichCompany( + EnrichmentProvider $provider, + Subscriber $subscriber, + $person_result, + bool $is_company_trigger, + int $min_likelihood, + string $data_behavior, + string $company_handling, + int $funnel_subscriber_id, + $sequence + ) { + // Determine company identifier. + $company_website = null; + $company_name = null; + + if ( $is_company_trigger && $subscriber->company_id ) { + $existing_company = Company::find( $subscriber->company_id ); + if ( $existing_company ) { + $company_website = $existing_company->website; + $company_name = $existing_company->name; + } + } + + if ( ! $company_website && $subscriber->email ) { + $domain = FreeEmailDetector::extractDomain( $subscriber->email ); + if ( $domain && ! FreeEmailDetector::isFreeEmail( $subscriber->email ) ) { + $company_website = $domain; + } + } + + // Fallback to person enrichment result. + if ( ! $company_website && $person_result instanceof PersonResult ) { + $company_website = $person_result->job_company_website; + $company_name = $person_result->job_company_name; + } + + if ( ! $company_website && ! $company_name ) { + $this->log( 'info', "No company identifier found for {$subscriber->email}, skipping company enrichment." ); + return null; + } + + $params = array_filter( [ + 'website' => $company_website, + 'name' => $company_name, + 'min_likelihood' => $min_likelihood, + ] ); + + $result = $provider->enrichCompany( $params ); + + if ( $result instanceof EnrichmentError ) { + $this->handleError( $result, $subscriber, $funnel_subscriber_id, $sequence, 'company' ); + return $result; + } + + // Determine match confidence. + $email_domain = $subscriber->email ? FreeEmailDetector::extractDomain( $subscriber->email ) : ''; + $result_domain = $this->normalizeDomain( $result->website ?? '' ); + $match_type = ( $email_domain && $result_domain && $email_domain === $result_domain ) ? 'confirmed' : 'inferred'; + + // Handle company based on setting. + $company = null; + + if ( 'none' === $company_handling ) { + $this->storeCompanyInCustomFields( $subscriber, $result, $match_type, $provider->getSlug(), $data_behavior ); + } elseif ( 'update_existing' === $company_handling ) { + if ( $subscriber->company_id ) { + $company = Company::find( $subscriber->company_id ); + if ( $company ) { + $this->updateCompanyFromResult( $company, $result, $match_type, $provider->getSlug(), $data_behavior ); + } + } + } else { + // create_or_update. + $company = $this->findOrCreateCompany( $result, $match_type, $provider->getSlug(), $data_behavior ); + if ( $company && ! $is_company_trigger && $subscriber->email ) { + $subscriber->company_id = $company->id; + $subscriber->save(); + $subscriber->attachCompanies( [ $company->id ] ); + } + } + + $this->log( 'info', "Company enrichment successful: {$result->name} ({$match_type})" ); + + do_action( 'custom_crm/company_enriched', $company, $result ); + + return $result; + } + + /** + * Find an existing company by website domain or name, or create a new one. + * + * @param CompanyResult $result The company enrichment result. + * @param string $match_type 'confirmed' or 'inferred'. + * @param string $provider_slug Provider slug. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + * + * @return Company|null + */ + private function findOrCreateCompany( + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): ?Company { + $company = null; + + // Try to find by website domain. + if ( $result->website ) { + $normalized = $this->normalizeDomain( $result->website ); + $company = Company::where( 'website', 'LIKE', "%{$normalized}%" )->first(); + } + + // Fallback: find by exact name. + if ( ! $company && $result->name ) { + $company = Company::where( 'name', $result->name )->first(); + } + + if ( $company ) { + $this->updateCompanyFromResult( $company, $result, $match_type, $provider_slug, $data_behavior ); + return $company; + } + + // Create new company. + $fields = $result->toCompanyFields(); + $fields['meta'] = $this->buildCompanyMeta( $result, $match_type, $provider_slug ); + + if ( empty( $fields['name'] ) ) { + return null; + } + + return Company::create( $fields ); + } + + /** + * Update an existing company from enrichment results. + * + * @param Company $company The company model. + * @param CompanyResult $result The company enrichment result. + * @param string $match_type 'confirmed' or 'inferred'. + * @param string $provider_slug Provider slug. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + */ + private function updateCompanyFromResult( + Company $company, + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): void { + $fields = $result->toCompanyFields(); + + if ( 'fill_empty' === $data_behavior ) { + $fields = $this->filterEmptyCompanyFields( $company, $fields ); + } + + if ( $fields ) { + $company->fill( $fields ); + $company->save(); + } + + // Always update meta. + $meta = $company->meta; + $enrichment_meta = $this->buildCompanyMeta( $result, $match_type, $provider_slug ); + $company->meta = array_merge( is_array( $meta ) ? $meta : [], $enrichment_meta ); + $company->save(); + } + + /** + * Build the enrichment portion of company meta. + * + * @param CompanyResult $result The company enrichment result. + * @param string $match_type 'confirmed' or 'inferred'. + * @param string $provider_slug Provider slug. + * + * @return array + */ + private function buildCompanyMeta( CompanyResult $result, string $match_type, string $provider_slug ): array { + return array_merge( + $result->toCompanyMeta(), + [ + 'enrichment_company_match' => $match_type, + 'enrichment_provider' => $provider_slug, + 'enriched_at' => current_time( 'mysql' ), + ] + ); + } + + /** + * Store company data in contact custom fields (when company_handling = 'none'). + * + * @param Subscriber $subscriber The subscriber. + * @param CompanyResult $result The company enrichment result. + * @param string $match_type 'confirmed' or 'inferred'. + * @param string $provider_slug Provider slug. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + */ + private function storeCompanyInCustomFields( + Subscriber $subscriber, + CompanyResult $result, + string $match_type, + string $provider_slug, + string $data_behavior + ): void { + EnrichmentFields::ensureFieldsExist(); + + $custom = [ + 'enrichment_company_name' => $result->name, + 'enrichment_industry' => $result->industry, + 'enrichment_company_match' => $match_type, + ]; + + $custom = array_filter( $custom, static fn( $v ) => null !== $v ); + + if ( 'fill_empty' === $data_behavior ) { + $custom = $this->filterEmptyCustomFieldsOnly( $subscriber, $custom ); + } + + if ( $custom ) { + $subscriber->syncCustomFieldValues( $custom, false ); + } + } + + /** + * Handle an enrichment error: log it and optionally mark the sequence. + * + * @param EnrichmentError $error The error. + * @param Subscriber $subscriber The subscriber. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $sequence The funnel sequence. + * @param string $context 'person' or 'company'. + */ + private function handleError( + EnrichmentError $error, + Subscriber $subscriber, + int $funnel_subscriber_id, + $sequence, + string $context + ): void { + $email = $subscriber->email ?? '(unknown)'; + + $log_level = in_array( $error->code, [ EnrichmentError::AUTH_FAILED, EnrichmentError::QUOTA_EXCEEDED, EnrichmentError::PROVIDER_ERROR ], true ) + ? 'error' + : 'info'; + + $this->log( $log_level, "{$context} enrichment error for {$email}: [{$error->code}] {$error->message}" ); + + // For fatal errors, skip the sequence. + if ( in_array( $error->code, [ EnrichmentError::AUTH_FAILED, EnrichmentError::INVALID_INPUT, EnrichmentError::QUOTA_EXCEEDED ], true ) ) { + FunnelHelper::changeFunnelSubSequenceStatus( $funnel_subscriber_id, $sequence->id, 'skipped' ); + } + } + + /** + * Filter subscriber fields to only those that are currently empty. + * + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Fields to filter. + * + * @return array + */ + private function filterEmptyOnly( Subscriber $subscriber, array $fields ): array { + return array_filter( + $fields, + static fn( $value, $key ) => empty( $subscriber->$key ), + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Filter custom fields to only those that are currently empty on the subscriber. + * + * Uses Subscriber::getMeta() to check each field individually since FluentCRM + * stores custom field values as individual meta rows. + * + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Custom field slug => value. + * + * @return array + */ + private function filterEmptyCustomFieldsOnly( Subscriber $subscriber, array $fields ): array { + return array_filter( + $fields, + static function ( $value, $key ) use ( $subscriber ) { + $existing = $subscriber->getMeta( $key, 'custom_field' ); + + // getMeta() returns false when the meta row doesn't exist. + // Also treat empty strings and null as "empty". + return false === $existing || '' === $existing || null === $existing; + }, + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Filter company fields to only those that are currently empty. + * + * @param Company $company Company model. + * @param array $fields Fields to filter. + * + * @return array + */ + private function filterEmptyCompanyFields( Company $company, array $fields ): array { + return array_filter( + $fields, + static fn( $value, $key ) => empty( $company->$key ), + ARRAY_FILTER_USE_BOTH + ); + } + + /** + * Detect if this is a company-triggered funnel. + * + * @param Subscriber $subscriber The subscriber. + * @param mixed $sequence The funnel sequence. + * + * @return bool + */ + private function isCompanyTriggered( Subscriber $subscriber, $sequence ): bool { + // Check funnel trigger name if available. + if ( isset( $sequence->funnel ) && isset( $sequence->funnel->trigger_name ) ) { + $trigger = $sequence->funnel->trigger_name; + if ( str_contains( $trigger, 'company' ) ) { + return true; + } + } + + return false; + } + + /** + * Normalize a domain for comparison (strip protocol, www, trailing slash). + * + * @param string $url The URL or domain to normalize. + * + * @return string + */ + private function normalizeDomain( string $url ): string { + $domain = strtolower( $url ); + $domain = preg_replace( '#^https?://#', '', $domain ); + $domain = preg_replace( '#^www\.#', '', $domain ); + $domain = rtrim( $domain, '/' ); + + return $domain; + } + + /** + * Get provider options for the dropdown. + * + * @return array + */ + private function getProviderOptions(): array { + $providers = $this->getRegisteredProviders(); + $options = []; + + foreach ( $providers as $provider ) { + $settings = EnrichmentSettings::getProviderSettings( $provider->getSlug() ); + if ( ! empty( $settings['api_key'] ) ) { + $options[] = [ + 'id' => $provider->getSlug(), + 'title' => $provider->getName(), + ]; + } + } + + if ( empty( $options ) ) { + $options[] = [ + 'id' => '', + 'title' => __( '-- No providers configured --', 'fluent-crm-custom-features' ), + ]; + } + + return $options; + } + + /** + * Resolve a provider instance by slug. + * + * @param string $slug Provider slug. + * + * @return EnrichmentProvider|null + */ + private function resolveProvider( string $slug ): ?EnrichmentProvider { + if ( '' === $slug ) { + $slug = EnrichmentSettings::getActiveProvider(); + } + + $providers = $this->getRegisteredProviders(); + + foreach ( $providers as $provider ) { + if ( $provider->getSlug() === $slug ) { + $settings = EnrichmentSettings::getProviderSettings( $slug ); + if ( ! empty( $settings['api_key'] ) ) { + return $provider; + } + } + } + + return null; + } + + /** + * Get all registered providers. + * + * @return EnrichmentProvider[] + */ + private function getRegisteredProviders(): array { + if ( $this->providers ) { + return $this->providers; + } + + $providers = [ + new PDLProvider(), + ]; + + /** + * Register additional enrichment providers. + * + * @param EnrichmentProvider[] $providers Array of provider instances. + */ + $this->providers = apply_filters( 'custom_crm/enrichment_providers', $providers ); + + return $this->providers; + } + + /** + * Log a message to FluentCRM's internal logger. + * + * @param string $level 'info', 'error', 'warning'. + * @param string $message Log message. + */ + private function log( string $level, string $message ): void { + if ( function_exists( 'fluentCrmLog' ) ) { + fluentCrmLog( $message ); + } + + if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( "[CustomCRM Enrichment][{$level}] {$message}" ); + } + } +} From 8b069ff33cab0f8c2a5301edf0035db333788885 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 16:59:27 -0400 Subject: [PATCH 25/30] fix(enrichment): resolve diagnostics in EnrichContactAction - Prefix unused callback params with underscore - Use strpos() instead of str_contains() for PHP 7.4 compat - Qualify fluentCrmLog() with global namespace --- classes/Actions/EnrichContactAction.php | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/classes/Actions/EnrichContactAction.php b/classes/Actions/EnrichContactAction.php index d0c3af3..9542193 100644 --- a/classes/Actions/EnrichContactAction.php +++ b/classes/Actions/EnrichContactAction.php @@ -563,7 +563,7 @@ private function storeCompanyInCustomFields( Subscriber $subscriber, CompanyResult $result, string $match_type, - string $provider_slug, + string $_provider_slug, string $data_behavior ): void { EnrichmentFields::ensureFieldsExist(); @@ -626,7 +626,7 @@ private function handleError( private function filterEmptyOnly( Subscriber $subscriber, array $fields ): array { return array_filter( $fields, - static fn( $value, $key ) => empty( $subscriber->$key ), + static fn( $_value, $key ) => empty( $subscriber->$key ), ARRAY_FILTER_USE_BOTH ); } @@ -645,7 +645,7 @@ private function filterEmptyOnly( Subscriber $subscriber, array $fields ): array private function filterEmptyCustomFieldsOnly( Subscriber $subscriber, array $fields ): array { return array_filter( $fields, - static function ( $value, $key ) use ( $subscriber ) { + static function ( $_value, $key ) use ( $subscriber ) { $existing = $subscriber->getMeta( $key, 'custom_field' ); // getMeta() returns false when the meta row doesn't exist. @@ -667,7 +667,7 @@ static function ( $value, $key ) use ( $subscriber ) { private function filterEmptyCompanyFields( Company $company, array $fields ): array { return array_filter( $fields, - static fn( $value, $key ) => empty( $company->$key ), + static fn( $_value, $key ) => empty( $company->$key ), ARRAY_FILTER_USE_BOTH ); } @@ -680,11 +680,11 @@ private function filterEmptyCompanyFields( Company $company, array $fields ): ar * * @return bool */ - private function isCompanyTriggered( Subscriber $subscriber, $sequence ): bool { + private function isCompanyTriggered( Subscriber $_subscriber, $sequence ): bool { // Check funnel trigger name if available. if ( isset( $sequence->funnel ) && isset( $sequence->funnel->trigger_name ) ) { $trigger = $sequence->funnel->trigger_name; - if ( str_contains( $trigger, 'company' ) ) { + if ( false !== strpos( $trigger, 'company' ) ) { return true; } } @@ -794,8 +794,8 @@ private function getRegisteredProviders(): array { * @param string $message Log message. */ private function log( string $level, string $message ): void { - if ( function_exists( 'fluentCrmLog' ) ) { - fluentCrmLog( $message ); + if ( function_exists( '\fluentCrmLog' ) ) { + \fluentCrmLog( $message ); // phpcs:ignore WordPress.NamingConventions.ValidFunctionName.FunctionNameInvalid } if ( defined( 'WP_DEBUG_LOG' ) && WP_DEBUG_LOG ) { From c96f444c48280625faf457e90505b2f91fad7058 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 16:59:46 -0400 Subject: [PATCH 26/30] feat(enrichment): register EnrichContactAction in plugin bootstrap --- fluent-crm-custom-features.php | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 032d202..608cf1b 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -66,6 +66,15 @@ function () { // Custom CSS editor for FluentCRM email templates. ( new \CustomCRM\Integrations\CustomEmailCSS() )->register(); + + // Timezone-aware email sending. + ( new \CustomCRM\Timezone\CampaignTimezoneHandler() )->register(); + ( new \CustomCRM\Timezone\AutomationTimezoneHandler() )->register(); + ( new \CustomCRM\Timezone\SequenceTimezoneHandler() )->register(); + ( new \CustomCRM\Timezone\TimezoneDetector() )->register(); + + // Contact and company enrichment via external providers (PDL, etc.). + ( new \CustomCRM\Actions\EnrichContactAction() ); }, 99 ); From bfdc9ad78d53a3d62acaad759690e63973e036d5 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 17:05:36 -0400 Subject: [PATCH 27/30] fix(lint): add required doc comments to enrichment DTOs and provider Convert all property @var comments from single-line to multi-line format, add short descriptions to EnrichmentError constants and properties, add missing @param tags to PDLProvider methods, and add short description to the constructor docblock to satisfy PHPCS Generic.Commenting and Squiz.Commenting rules. Zero PHPCS errors remain. --- classes/Enrichment/CompanyResult.php | 198 ++++++++++++++-- classes/Enrichment/EnrichmentError.php | 49 +++- classes/Enrichment/PersonResult.php | 226 ++++++++++++++++--- classes/Enrichment/Providers/PDLProvider.php | 103 +++++---- 4 files changed, 463 insertions(+), 113 deletions(-) diff --git a/classes/Enrichment/CompanyResult.php b/classes/Enrichment/CompanyResult.php index fcdd4b8..ce0f33e 100644 --- a/classes/Enrichment/CompanyResult.php +++ b/classes/Enrichment/CompanyResult.php @@ -15,34 +15,184 @@ class CompanyResult { // --- Native Company fields --- - public ?string $name = null; - public ?string $industry = null; - public ?string $type = null; - public ?string $website = null; - public ?string $email = null; - public ?string $phone = null; - public ?string $address_line_1 = null; - public ?string $city = null; - public ?string $state = null; - public ?string $country = null; - public ?string $postal_code = null; - public ?int $employees_number = null; - public ?string $description = null; - public ?string $logo = null; - public ?string $linkedin_url = null; - public ?string $facebook_url = null; - public ?string $twitter_url = null; - public ?string $date_of_start = null; + + /** + * Company display name. + * + * @var string|null + */ + public ?string $name = null; + + /** + * Industry sector. + * + * @var string|null + */ + public ?string $industry = null; + + /** + * Company type (e.g. public, private). + * + * @var string|null + */ + public ?string $type = null; + + /** + * Company website URL. + * + * @var string|null + */ + public ?string $website = null; + + /** + * Company contact email. + * + * @var string|null + */ + public ?string $email = null; + + /** + * Company phone number. + * + * @var string|null + */ + public ?string $phone = null; + + /** + * Street address line 1. + * + * @var string|null + */ + public ?string $address_line_1 = null; + + /** + * City. + * + * @var string|null + */ + public ?string $city = null; + + /** + * State or region. + * + * @var string|null + */ + public ?string $state = null; + + /** + * Country. + * + * @var string|null + */ + public ?string $country = null; + + /** + * Postal code. + * + * @var string|null + */ + public ?string $postal_code = null; + + /** + * Number of employees. + * + * @var int|null + */ + public ?int $employees_number = null; + + /** + * Company description. + * + * @var string|null + */ + public ?string $description = null; + + /** + * Logo URL. + * + * @var string|null + */ + public ?string $logo = null; + + /** + * LinkedIn company page URL. + * + * @var string|null + */ + public ?string $linkedin_url = null; + + /** + * Facebook company page URL. + * + * @var string|null + */ + public ?string $facebook_url = null; + + /** + * Twitter/X company profile URL. + * + * @var string|null + */ + public ?string $twitter_url = null; + + /** + * Founded year or date. + * + * @var string|null + */ + public ?string $date_of_start = null; // --- Extra metadata (stored in Company meta JSON) --- - public ?int $funding_raised = null; - public ?string $funding_stage = null; - public ?string $inferred_revenue = null; - public ?float $employee_growth_rate_12mo = null; - public ?string $ticker = null; - public ?array $naics_codes = null; + + /** + * Total funding raised (USD). + * + * @var int|null + */ + public ?int $funding_raised = null; + + /** + * Latest funding stage (e.g. Series B). + * + * @var string|null + */ + public ?string $funding_stage = null; + + /** + * Inferred annual revenue range. + * + * @var string|null + */ + public ?string $inferred_revenue = null; + + /** + * Employee growth rate over the past 12 months. + * + * @var float|null + */ + public ?float $employee_growth_rate_12mo = null; + + /** + * Stock ticker symbol. + * + * @var string|null + */ + public ?string $ticker = null; + + /** + * NAICS industry codes. + * + * @var array|null + */ + public ?array $naics_codes = null; // --- Enrichment metadata --- + + /** + * Match likelihood score (0-10). + * + * @var int|null + */ public ?int $likelihood = null; /** diff --git a/classes/Enrichment/EnrichmentError.php b/classes/Enrichment/EnrichmentError.php index 3277174..a0556d9 100644 --- a/classes/Enrichment/EnrichmentError.php +++ b/classes/Enrichment/EnrichmentError.php @@ -12,27 +12,58 @@ */ class EnrichmentError { - public const NO_MATCH = 'no_match'; - public const INVALID_INPUT = 'invalid_input'; - public const AUTH_FAILED = 'auth_failed'; - public const RATE_LIMITED = 'rate_limited'; + /** No matching record found. */ + public const NO_MATCH = 'no_match'; + + /** Input data was invalid or insufficient. */ + public const INVALID_INPUT = 'invalid_input'; + + /** API authentication failed (bad or missing key). */ + public const AUTH_FAILED = 'auth_failed'; + + /** Request was rate-limited by the provider. */ + public const RATE_LIMITED = 'rate_limited'; + + /** API quota or credit limit exceeded. */ public const QUOTA_EXCEEDED = 'quota_exceeded'; + + /** The provider returned an unexpected server error. */ public const PROVIDER_ERROR = 'provider_error'; - public const NETWORK_ERROR = 'network_error'; - /** @var string Normalized error code (use class constants). */ + /** A network-level error occurred before a response was received. */ + public const NETWORK_ERROR = 'network_error'; + + /** + * Normalized error code (use class constants). + * + * @var string + */ public string $code; - /** @var string Human-readable error message. */ + /** + * Human-readable error message. + * + * @var string + */ public string $message; - /** @var int|null Raw HTTP status from the provider. */ + /** + * Raw HTTP status from the provider. + * + * @var int|null + */ public ?int $http_status; - /** @var bool Whether the caller should retry. */ + /** + * Whether the caller should retry. + * + * @var bool + */ public bool $retryable; /** + * Create a new enrichment error. + * * @param string $code One of the class constants. * @param string $message Human-readable message. * @param int|null $http_status Raw HTTP status code. diff --git a/classes/Enrichment/PersonResult.php b/classes/Enrichment/PersonResult.php index 9c55052..5999fa3 100644 --- a/classes/Enrichment/PersonResult.php +++ b/classes/Enrichment/PersonResult.php @@ -15,37 +15,199 @@ class PersonResult { // --- Native Subscriber fields --- - public ?string $first_name = null; - public ?string $last_name = null; - public ?string $phone = null; - public ?string $city = null; - public ?string $state = null; - public ?string $country = null; - public ?string $postal_code = null; + + /** + * First name. + * + * @var string|null + */ + public ?string $first_name = null; + + /** + * Last name. + * + * @var string|null + */ + public ?string $last_name = null; + + /** + * Phone number. + * + * @var string|null + */ + public ?string $phone = null; + + /** + * City. + * + * @var string|null + */ + public ?string $city = null; + + /** + * State or region. + * + * @var string|null + */ + public ?string $state = null; + + /** + * Country. + * + * @var string|null + */ + public ?string $country = null; + + /** + * Postal code. + * + * @var string|null + */ + public ?string $postal_code = null; + + /** + * Street address line 1. + * + * @var string|null + */ public ?string $address_line_1 = null; + + /** + * Date of birth (YYYY-MM-DD). + * + * @var string|null + */ public ?string $date_of_birth = null; - public ?string $timezone = null; - public ?float $latitude = null; - public ?float $longitude = null; - public ?string $avatar = null; + + /** + * Timezone identifier. + * + * @var string|null + */ + public ?string $timezone = null; + + /** + * Latitude coordinate. + * + * @var float|null + */ + public ?float $latitude = null; + + /** + * Longitude coordinate. + * + * @var float|null + */ + public ?float $longitude = null; + + /** + * Avatar URL. + * + * @var string|null + */ + public ?string $avatar = null; // --- Custom fields --- - public ?string $job_title = null; - public ?string $job_role = null; - public ?string $job_level = null; - public ?string $job_company_name = null; + + /** + * Job title. + * + * @var string|null + */ + public ?string $job_title = null; + + /** + * Job role category. + * + * @var string|null + */ + public ?string $job_role = null; + + /** + * Job seniority level. + * + * @var string|null + */ + public ?string $job_level = null; + + /** + * Employer company name. + * + * @var string|null + */ + public ?string $job_company_name = null; + + /** + * Employer company website. + * + * @var string|null + */ public ?string $job_company_website = null; - public ?string $linkedin_url = null; - public ?string $twitter_url = null; - public ?string $facebook_url = null; - public ?string $github_url = null; - public ?string $sex = null; - public ?string $pronouns = null; - public ?string $inferred_salary = null; - public ?string $industry = null; + + /** + * LinkedIn profile URL. + * + * @var string|null + */ + public ?string $linkedin_url = null; + + /** + * Twitter/X profile URL. + * + * @var string|null + */ + public ?string $twitter_url = null; + + /** + * Facebook profile URL. + * + * @var string|null + */ + public ?string $facebook_url = null; + + /** + * GitHub profile URL. + * + * @var string|null + */ + public ?string $github_url = null; + + /** + * Sex (male/female). + * + * @var string|null + */ + public ?string $sex = null; + + /** + * Pronouns (e.g. he/him). + * + * @var string|null + */ + public ?string $pronouns = null; + + /** + * Inferred salary range. + * + * @var string|null + */ + public ?string $inferred_salary = null; + + /** + * Industry. + * + * @var string|null + */ + public ?string $industry = null; // --- Enrichment metadata --- - public ?int $likelihood = null; + + /** + * Match likelihood score (0-10). + * + * @var int|null + */ + public ?int $likelihood = null; /** * Return fields that map to native Subscriber columns. @@ -79,14 +241,14 @@ public function toSubscriberFields(): array { */ public function toCustomFields(): array { $map = [ - 'enrichment_job_title' => $this->job_title, - 'enrichment_job_role' => $this->job_role, - 'enrichment_job_level' => $this->job_level, - 'enrichment_company_name' => $this->job_company_name, - 'enrichment_linkedin_url' => $this->linkedin_url, - 'enrichment_twitter_url' => $this->twitter_url, - 'enrichment_facebook_url' => $this->facebook_url, - 'enrichment_github_url' => $this->github_url, + 'enrichment_job_title' => $this->job_title, + 'enrichment_job_role' => $this->job_role, + 'enrichment_job_level' => $this->job_level, + 'enrichment_company_name' => $this->job_company_name, + 'enrichment_linkedin_url' => $this->linkedin_url, + 'enrichment_twitter_url' => $this->twitter_url, + 'enrichment_facebook_url' => $this->facebook_url, + 'enrichment_github_url' => $this->github_url, 'enrichment_sex' => $this->sex, 'enrichment_pronouns' => $this->pronouns, 'enrichment_inferred_salary' => $this->inferred_salary, diff --git a/classes/Enrichment/Providers/PDLProvider.php b/classes/Enrichment/Providers/PDLProvider.php index f7d229f..8387241 100644 --- a/classes/Enrichment/Providers/PDLProvider.php +++ b/classes/Enrichment/Providers/PDLProvider.php @@ -63,6 +63,8 @@ public function getSettingsFields(): array { /** * {@inheritDoc} + * + * @param array $settings Provider settings to validate. */ public function validateSettings( array $settings ) { $api_key = $settings['api_key'] ?? ''; @@ -100,6 +102,8 @@ public function validateSettings( array $settings ) { /** * {@inheritDoc} + * + * @param array $params Enrichment parameters (email, first_name, etc.). */ public function enrichPerson( array $params ) { $api_key = $this->getApiKey(); @@ -115,31 +119,33 @@ public function enrichPerson( array $params ) { $query = array_filter( [ - 'email' => $params['email'] ?? '', - 'first_name' => $params['first_name'] ?? '', - 'last_name' => $params['last_name'] ?? '', - 'company' => $params['company'] ?? '', - 'location' => $params['location'] ?? '', - 'min_likelihood' => $params['min_likelihood'] ?? 6, + 'email' => $params['email'] ?? '', + 'first_name' => $params['first_name'] ?? '', + 'last_name' => $params['last_name'] ?? '', + 'company' => $params['company'] ?? '', + 'location' => $params['location'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 6, 'include_if_matched' => 'true', - 'titlecase' => 'true', - 'pretty' => 'false', + 'titlecase' => 'true', + 'pretty' => 'false', ], static fn( $v ) => '' !== $v && null !== $v ); - $url = add_query_arg( $query, self::PERSON_ENDPOINT ); - $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + $url = add_query_arg( $query, self::PERSON_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); - if ( $result instanceof EnrichmentError ) { - return $result; - } + if ( $result instanceof EnrichmentError ) { + return $result; + } - return $this->mapPersonResponse( $result['body'] ); + return $this->mapPersonResponse( $result['body'] ); } /** * {@inheritDoc} + * + * @param array $params Enrichment parameters (website, name, linkedin_url, etc.). */ public function enrichCompany( array $params ) { $api_key = $this->getApiKey(); @@ -155,29 +161,32 @@ public function enrichCompany( array $params ) { $query = array_filter( [ - 'website' => $params['website'] ?? '', - 'name' => $params['name'] ?? '', - 'profile' => $params['linkedin_url'] ?? '', - 'min_likelihood' => $params['min_likelihood'] ?? 2, + 'website' => $params['website'] ?? '', + 'name' => $params['name'] ?? '', + 'profile' => $params['linkedin_url'] ?? '', + 'min_likelihood' => $params['min_likelihood'] ?? 2, 'include_if_matched' => 'true', - 'titlecase' => 'true', - 'pretty' => 'false', + 'titlecase' => 'true', + 'pretty' => 'false', ], static fn( $v ) => '' !== $v && null !== $v ); - $url = add_query_arg( $query, self::COMPANY_ENDPOINT ); - $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); + $url = add_query_arg( $query, self::COMPANY_ENDPOINT ); + $result = $this->httpGet( $url, [ 'X-Api-Key' => $api_key ] ); - if ( $result instanceof EnrichmentError ) { - return $result; - } + if ( $result instanceof EnrichmentError ) { + return $result; + } - return $this->mapCompanyResponse( $result['body'] ); + return $this->mapCompanyResponse( $result['body'] ); } /** * {@inheritDoc} + * + * @param int $http_status HTTP status code from the provider. + * @param array $response_body Decoded response body. */ protected function mapError( int $http_status, array $response_body ): EnrichmentError { $message = $response_body['error']['message'] ?? "HTTP {$http_status}"; @@ -185,7 +194,7 @@ protected function mapError( int $http_status, array $response_body ): Enrichmen return match ( $http_status ) { 400 => new EnrichmentError( EnrichmentError::INVALID_INPUT, $message, 400, false ), 401 => new EnrichmentError( EnrichmentError::AUTH_FAILED, $message, 401, false ), - 402,403 => new EnrichmentError( EnrichmentError::QUOTA_EXCEEDED, $message, $http_status, false ), + 402, 403 => new EnrichmentError( EnrichmentError::QUOTA_EXCEEDED, $message, $http_status, false ), 404 => new EnrichmentError( EnrichmentError::NO_MATCH, 'No matching record found.', 404, false ), 429 => new EnrichmentError( EnrichmentError::RATE_LIMITED, $message, 429, true ), default => new EnrichmentError( @@ -208,24 +217,24 @@ private function mapPersonResponse( array $body ): PersonResult { $data = $body['data'] ?? $body; $result = new PersonResult(); - $result->first_name = $data['first_name'] ?? null; - $result->last_name = $data['last_name'] ?? null; - $result->phone = $data['mobile_phone'] ?? ( $data['phone_numbers'][0] ?? null ); - $result->city = $data['location_locality'] ?? null; - $result->state = $data['location_region'] ?? null; - $result->country = $data['location_country'] ?? null; - $result->postal_code = $data['location_postal_code'] ?? null; - $result->address_line_1 = $data['location_street_address'] ?? null; - $result->date_of_birth = $data['birth_date'] ?? null; - $result->linkedin_url = $data['linkedin_url'] ?? null; - $result->twitter_url = $data['twitter_url'] ?? null; - $result->facebook_url = $data['facebook_url'] ?? null; - $result->github_url = $data['github_url'] ?? null; - $result->job_title = $data['job_title'] ?? null; - $result->job_role = $data['job_title_role'] ?? null; - $result->industry = $data['industry'] ?? ( $data['job_company_industry'] ?? null ); + $result->first_name = $data['first_name'] ?? null; + $result->last_name = $data['last_name'] ?? null; + $result->phone = $data['mobile_phone'] ?? ( $data['phone_numbers'][0] ?? null ); + $result->city = $data['location_locality'] ?? null; + $result->state = $data['location_region'] ?? null; + $result->country = $data['location_country'] ?? null; + $result->postal_code = $data['location_postal_code'] ?? null; + $result->address_line_1 = $data['location_street_address'] ?? null; + $result->date_of_birth = $data['birth_date'] ?? null; + $result->linkedin_url = $data['linkedin_url'] ?? null; + $result->twitter_url = $data['twitter_url'] ?? null; + $result->facebook_url = $data['facebook_url'] ?? null; + $result->github_url = $data['github_url'] ?? null; + $result->job_title = $data['job_title'] ?? null; + $result->job_role = $data['job_title_role'] ?? null; + $result->industry = $data['industry'] ?? ( $data['job_company_industry'] ?? null ); $result->inferred_salary = $data['inferred_salary'] ?? null; - $result->sex = $data['sex'] ?? null; + $result->sex = $data['sex'] ?? null; // Derive pronouns from sex if available. if ( $result->sex && null === $result->pronouns ) { @@ -237,7 +246,7 @@ private function mapPersonResponse( array $body ): PersonResult { } // Job level: PDL returns an array, take the first. - $levels = $data['job_title_levels'] ?? []; + $levels = $data['job_title_levels'] ?? []; $result->job_level = is_array( $levels ) && $levels ? $levels[0] : null; // Company data from person response. @@ -247,14 +256,13 @@ private function mapPersonResponse( array $body ): PersonResult { // Geo coordinates. $geo = $data['location_geo'] ?? null; if ( $geo && is_string( $geo ) && str_contains( $geo, ',' ) ) { - $parts = explode( ',', $geo ); + $parts = explode( ',', $geo ); $result->latitude = (float) trim( $parts[0] ); $result->longitude = (float) trim( $parts[1] ); } $result->likelihood = $body['likelihood'] ?? null; - /** @var PersonResult */ return apply_filters( 'custom_crm/enrichment_person_result', $result, $body ); } @@ -312,7 +320,6 @@ private function mapCompanyResponse( array $body ): CompanyResult { $result->likelihood = $body['likelihood'] ?? null; - /** @var CompanyResult */ return apply_filters( 'custom_crm/enrichment_company_result', $result, $body ); } From 74b9a241e42a7991f252591ac10449e0bb5ee000 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 17:06:23 -0400 Subject: [PATCH 28/30] fix(enrichment): resolve all PHPCS lint errors across enrichment classes --- classes/Actions/EnrichContactAction.php | 71 ++++++++++++----------- classes/Enrichment/EnrichmentFields.php | 4 +- classes/Enrichment/EnrichmentProvider.php | 4 +- classes/Enrichment/EnrichmentSettings.php | 12 ++-- 4 files changed, 47 insertions(+), 44 deletions(-) diff --git a/classes/Actions/EnrichContactAction.php b/classes/Actions/EnrichContactAction.php index 9542193..a8538b2 100644 --- a/classes/Actions/EnrichContactAction.php +++ b/classes/Actions/EnrichContactAction.php @@ -34,6 +34,9 @@ class EnrichContactAction extends BaseAction { */ private array $providers = []; + /** + * EnrichContactAction constructor. + */ public function __construct() { // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase $this->actionName = 'enrich_contact'; @@ -124,9 +127,9 @@ public function getBlockFields() { 'check_label' => __( 'Re-enrich contacts that have already been enriched', 'fluent-crm-custom-features' ), ], 'company_handling' => [ - 'type' => 'radio', - 'label' => __( 'Company Handling', 'fluent-crm-custom-features' ), - 'options' => [ + 'type' => 'radio', + 'label' => __( 'Company Handling', 'fluent-crm-custom-features' ), + 'options' => [ [ 'id' => 'create_or_update', 'title' => __( 'Create or update company', 'fluent-crm-custom-features' ), @@ -167,10 +170,10 @@ public function getBlockFields() { /** * Execute the enrichment action. * - * @param \FluentCrm\App\Models\Subscriber $subscriber The subscriber. - * @param \FluentCrm\App\Models\FunnelSequence $sequence The funnel sequence. - * @param int $funnel_subscriber_id Funnel subscriber ID. - * @param mixed $funnel_metric Funnel metric. + * @param \FluentCrm\App\Models\Subscriber $subscriber The subscriber. + * @param \FluentCrm\App\Models\FunnelSequence $sequence The funnel sequence. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $funnel_metric Funnel metric. */ public function handle( $subscriber, $sequence, $funnel_subscriber_id, $funnel_metric ) { $settings = $sequence->settings; @@ -274,11 +277,11 @@ public function handle( $subscriber, $sequence, $funnel_subscriber_id, $funnel_m * Run person enrichment and apply results to the subscriber. * * @param EnrichmentProvider $provider The provider. - * @param Subscriber $subscriber The subscriber. - * @param int $min_likelihood Minimum likelihood threshold. - * @param string $data_behavior 'fill_empty' or 'overwrite'. - * @param int $funnel_subscriber_id Funnel subscriber ID. - * @param mixed $sequence The funnel sequence. + * @param Subscriber $subscriber The subscriber. + * @param int $min_likelihood Minimum likelihood threshold. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $sequence The funnel sequence. * * @return PersonResult|EnrichmentError|null */ @@ -328,7 +331,7 @@ private function enrichPerson( } // Map custom fields. - $custom_fields = $result->toCustomFields(); + $custom_fields = $result->toCustomFields(); $custom_fields['enriched_at'] = current_time( 'mysql' ); $custom_fields['enrichment_provider'] = $provider->getSlug(); @@ -352,14 +355,14 @@ private function enrichPerson( * Run company enrichment and apply results. * * @param EnrichmentProvider $provider The provider. - * @param Subscriber $subscriber The subscriber. - * @param mixed $person_result PersonResult or null. - * @param bool $is_company_trigger Whether this is a company-triggered funnel. - * @param int $min_likelihood Minimum likelihood threshold. - * @param string $data_behavior 'fill_empty' or 'overwrite'. - * @param string $company_handling Company handling mode. - * @param int $funnel_subscriber_id Funnel subscriber ID. - * @param mixed $sequence The funnel sequence. + * @param Subscriber $subscriber The subscriber. + * @param mixed $person_result PersonResult or null. + * @param bool $is_company_trigger Whether this is a company-triggered funnel. + * @param int $min_likelihood Minimum likelihood threshold. + * @param string $data_behavior 'fill_empty' or 'overwrite'. + * @param string $company_handling Company handling mode. + * @param int $funnel_subscriber_id Funnel subscriber ID. + * @param mixed $sequence The funnel sequence. * * @return CompanyResult|EnrichmentError|null */ @@ -553,11 +556,11 @@ private function buildCompanyMeta( CompanyResult $result, string $match_type, st /** * Store company data in contact custom fields (when company_handling = 'none'). * - * @param Subscriber $subscriber The subscriber. - * @param CompanyResult $result The company enrichment result. - * @param string $match_type 'confirmed' or 'inferred'. - * @param string $provider_slug Provider slug. - * @param string $data_behavior 'fill_empty' or 'overwrite'. + * @param Subscriber $subscriber The subscriber. + * @param CompanyResult $result The company enrichment result. + * @param string $match_type 'confirmed' or 'inferred'. + * @param string $_provider_slug Provider slug (reserved for future use). + * @param string $data_behavior 'fill_empty' or 'overwrite'. */ private function storeCompanyInCustomFields( Subscriber $subscriber, @@ -618,8 +621,8 @@ private function handleError( /** * Filter subscriber fields to only those that are currently empty. * - * @param Subscriber $subscriber Subscriber model. - * @param array $fields Fields to filter. + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Fields to filter. * * @return array */ @@ -637,8 +640,8 @@ private function filterEmptyOnly( Subscriber $subscriber, array $fields ): array * Uses Subscriber::getMeta() to check each field individually since FluentCRM * stores custom field values as individual meta rows. * - * @param Subscriber $subscriber Subscriber model. - * @param array $fields Custom field slug => value. + * @param Subscriber $subscriber Subscriber model. + * @param array $fields Custom field slug => value. * * @return array */ @@ -659,8 +662,8 @@ static function ( $_value, $key ) use ( $subscriber ) { /** * Filter company fields to only those that are currently empty. * - * @param Company $company Company model. - * @param array $fields Fields to filter. + * @param Company $company Company model. + * @param array $fields Fields to filter. * * @return array */ @@ -675,8 +678,8 @@ private function filterEmptyCompanyFields( Company $company, array $fields ): ar /** * Detect if this is a company-triggered funnel. * - * @param Subscriber $subscriber The subscriber. - * @param mixed $sequence The funnel sequence. + * @param Subscriber $_subscriber The subscriber (unused, context detection uses sequence). + * @param mixed $sequence The funnel sequence. * * @return bool */ diff --git a/classes/Enrichment/EnrichmentFields.php b/classes/Enrichment/EnrichmentFields.php index 0478eef..c451cc0 100644 --- a/classes/Enrichment/EnrichmentFields.php +++ b/classes/Enrichment/EnrichmentFields.php @@ -23,9 +23,9 @@ public static function ensureFieldsExist(): void { return; } - $existing = fluentcrm_get_option( 'contact_custom_fields', [] ); + $existing = fluentcrm_get_option( 'contact_custom_fields', [] ); $existing_slugs = array_column( $existing, 'slug' ); - $added = false; + $added = false; foreach ( self::getFieldDefinitions() as $field ) { if ( ! in_array( $field['slug'], $existing_slugs, true ) ) { diff --git a/classes/Enrichment/EnrichmentProvider.php b/classes/Enrichment/EnrichmentProvider.php index 1b635b5..0af5533 100644 --- a/classes/Enrichment/EnrichmentProvider.php +++ b/classes/Enrichment/EnrichmentProvider.php @@ -66,8 +66,8 @@ abstract public function validateSettings( array $settings ); /** * Map a provider-specific HTTP error to a normalized EnrichmentError. * - * @param int $http_status HTTP status code. - * @param array $response_body Decoded response body. + * @param int $http_status HTTP status code. + * @param array $response_body Decoded response body. * * @return EnrichmentError */ diff --git a/classes/Enrichment/EnrichmentSettings.php b/classes/Enrichment/EnrichmentSettings.php index 3d3ff3b..df51361 100644 --- a/classes/Enrichment/EnrichmentSettings.php +++ b/classes/Enrichment/EnrichmentSettings.php @@ -59,8 +59,8 @@ public static function getProviderSettings( string $slug ): array { * @param array $settings Provider-specific settings. */ public static function saveProviderSettings( string $slug, array $settings ): void { - $all = self::getAll(); - $all['providers'][ $slug ] = $settings; + $all = self::getAll(); + $all['providers'][ $slug ] = $settings; // Auto-set active provider if none set. if ( empty( $all['active_provider'] ) ) { @@ -111,8 +111,8 @@ public static function encrypt( string $value ): string { $key = self::getEncryptionKey(); if ( function_exists( 'openssl_encrypt' ) ) { - $iv = openssl_random_pseudo_bytes( 16 ); - $encrypted = openssl_encrypt( $value, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); + $iv = openssl_random_pseudo_bytes( 16 ); + $encrypted = openssl_encrypt( $value, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); if ( false !== $encrypted ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode @@ -135,8 +135,8 @@ public static function encrypt( string $value ): string { public static function decrypt( string $encrypted ): string { if ( str_starts_with( $encrypted, 'enc:' ) && function_exists( 'openssl_decrypt' ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode - $raw = base64_decode( substr( $encrypted, 4 ) ); - $iv = substr( $raw, 0, 16 ); + $raw = base64_decode( substr( $encrypted, 4 ) ); + $iv = substr( $raw, 0, 16 ); $data = substr( $raw, 16 ); $key = self::getEncryptionKey(); From 59c8ff548ea939cca95f6c2f51ba793d99a1e930 Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 17:17:57 -0400 Subject: [PATCH 29/30] fix(enrichment): address CodeRabbit review findings - Normalize filtered free email domains to lowercase - Require OpenSSL for encryption, return empty string on failure - Handle decrypt failures gracefully (return empty, don't leak ciphertext) - Warn when AUTH_KEY is missing (weak fallback key) - Guard company_enriched hook against null company - Escape LIKE wildcards in company website lookup - Handle preg_replace null returns in normalizeDomain --- classes/Actions/EnrichContactAction.php | 11 ++-- classes/Enrichment/EnrichmentSettings.php | 66 ++++++++++++++++------- classes/Enrichment/FreeEmailDetector.php | 3 +- 3 files changed, 56 insertions(+), 24 deletions(-) diff --git a/classes/Actions/EnrichContactAction.php b/classes/Actions/EnrichContactAction.php index a8538b2..26f8bf1 100644 --- a/classes/Actions/EnrichContactAction.php +++ b/classes/Actions/EnrichContactAction.php @@ -449,7 +449,9 @@ private function enrichCompany( $this->log( 'info', "Company enrichment successful: {$result->name} ({$match_type})" ); - do_action( 'custom_crm/company_enriched', $company, $result ); + if ( $company ) { + do_action( 'custom_crm/company_enriched', $company, $result ); + } return $result; } @@ -475,7 +477,8 @@ private function findOrCreateCompany( // Try to find by website domain. if ( $result->website ) { $normalized = $this->normalizeDomain( $result->website ); - $company = Company::where( 'website', 'LIKE', "%{$normalized}%" )->first(); + $escaped = addcslashes( $normalized, '%_' ); + $company = Company::where( 'website', 'LIKE', "%{$escaped}%" )->first(); } // Fallback: find by exact name. @@ -704,8 +707,8 @@ private function isCompanyTriggered( Subscriber $_subscriber, $sequence ): bool */ private function normalizeDomain( string $url ): string { $domain = strtolower( $url ); - $domain = preg_replace( '#^https?://#', '', $domain ); - $domain = preg_replace( '#^www\.#', '', $domain ); + $domain = preg_replace( '#^https?://#', '', $domain ) ?? $domain; + $domain = preg_replace( '#^www\.#', '', $domain ) ?? $domain; $domain = rtrim( $domain, '/' ); return $domain; diff --git a/classes/Enrichment/EnrichmentSettings.php b/classes/Enrichment/EnrichmentSettings.php index df51361..b70c914 100644 --- a/classes/Enrichment/EnrichmentSettings.php +++ b/classes/Enrichment/EnrichmentSettings.php @@ -103,53 +103,73 @@ public static function getApiKey( string $slug ): string { /** * Encrypt a value for storage using OpenSSL with WordPress AUTH_KEY as the key. * + * Requires the OpenSSL extension. Returns empty string on failure. + * * @param string $value Plain text value. * - * @return string Encrypted string (base64-encoded with 'enc:' prefix). + * @return string Encrypted string (base64-encoded with 'enc:' prefix), or empty on failure. */ public static function encrypt( string $value ): string { + if ( ! function_exists( 'openssl_encrypt' ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( '[CustomCRM Enrichment] OpenSSL extension required for API key encryption.' ); + return ''; + } + $key = self::getEncryptionKey(); + $iv = openssl_random_pseudo_bytes( 16 ); - if ( function_exists( 'openssl_encrypt' ) ) { - $iv = openssl_random_pseudo_bytes( 16 ); - $encrypted = openssl_encrypt( $value, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); + if ( false === $iv ) { + return ''; + } - if ( false !== $encrypted ) { - // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode - return 'enc:' . base64_encode( $iv . $encrypted ); - } + $encrypted = openssl_encrypt( $value, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); + + if ( false === $encrypted ) { + return ''; } - // Fallback if OpenSSL unavailable: base64 obfuscation only. // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode - return 'b64:' . base64_encode( $value ); + return 'enc:' . base64_encode( $iv . $encrypted ); } /** * Decrypt a stored value. * + * Returns empty string if decryption fails (corrupted data, missing OpenSSL, etc.). + * * @param string $encrypted Encrypted string. * - * @return string Decrypted value. + * @return string Decrypted value, or empty string on failure. */ public static function decrypt( string $encrypted ): string { - if ( str_starts_with( $encrypted, 'enc:' ) && function_exists( 'openssl_decrypt' ) ) { + if ( 0 === strpos( $encrypted, 'enc:' ) ) { + if ( ! function_exists( 'openssl_decrypt' ) ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( '[CustomCRM Enrichment] OpenSSL extension required to decrypt API key.' ); + return ''; + } + // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode - $raw = base64_decode( substr( $encrypted, 4 ) ); + $raw = base64_decode( substr( $encrypted, 4 ), true ); + + if ( false === $raw || strlen( $raw ) < 17 ) { + return ''; + } + $iv = substr( $raw, 0, 16 ); $data = substr( $raw, 16 ); $key = self::getEncryptionKey(); $decrypted = openssl_decrypt( $data, 'aes-256-cbc', $key, OPENSSL_RAW_DATA, $iv ); - if ( false !== $decrypted ) { - return $decrypted; - } + return ( false !== $decrypted ) ? $decrypted : ''; } - if ( str_starts_with( $encrypted, 'b64:' ) ) { + if ( 0 === strpos( $encrypted, 'b64:' ) ) { // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode - return base64_decode( substr( $encrypted, 4 ) ); + $decoded = base64_decode( substr( $encrypted, 4 ), true ); + return ( false !== $decoded ) ? $decoded : ''; } // Plain text from old storage. @@ -159,10 +179,18 @@ public static function decrypt( string $encrypted ): string { /** * Derive an encryption key from WordPress salts. * + * Logs a warning if AUTH_KEY is not defined (shared fallback key is insecure). + * * @return string 32-byte key. */ private static function getEncryptionKey(): string { - $salt = defined( 'AUTH_KEY' ) ? AUTH_KEY : 'custom-crm-fallback-key'; + if ( ! defined( 'AUTH_KEY' ) || '' === AUTH_KEY ) { + // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log + error_log( '[CustomCRM Enrichment] AUTH_KEY not defined; using weak fallback for encryption.' ); + $salt = 'custom-crm-fallback-key'; + } else { + $salt = AUTH_KEY; + } return hash( 'sha256', $salt, true ); } diff --git a/classes/Enrichment/FreeEmailDetector.php b/classes/Enrichment/FreeEmailDetector.php index bcb9d57..ceab60b 100644 --- a/classes/Enrichment/FreeEmailDetector.php +++ b/classes/Enrichment/FreeEmailDetector.php @@ -71,8 +71,9 @@ public static function isFreeEmail( string $email ): bool { * @param string[] $domains Array of domain strings (e.g., 'gmail.com'). */ $domains = apply_filters( 'custom_crm/free_email_domains', self::DOMAINS ); + $domains = array_map( 'strtolower', $domains ); - return in_array( strtolower( $domain ), $domains, true ); + return in_array( $domain, $domains, true ); } /** From 15fc09a68b248de70b496fe6d65c4a0e542acd7e Mon Sep 17 00:00:00 2001 From: Zack Katz Date: Thu, 26 Mar 2026 20:21:09 -0400 Subject: [PATCH 30/30] Remove timezone features for now --- fluent-crm-custom-features.php | 6 ------ 1 file changed, 6 deletions(-) diff --git a/fluent-crm-custom-features.php b/fluent-crm-custom-features.php index 608cf1b..21109e9 100644 --- a/fluent-crm-custom-features.php +++ b/fluent-crm-custom-features.php @@ -67,12 +67,6 @@ function () { // Custom CSS editor for FluentCRM email templates. ( new \CustomCRM\Integrations\CustomEmailCSS() )->register(); - // Timezone-aware email sending. - ( new \CustomCRM\Timezone\CampaignTimezoneHandler() )->register(); - ( new \CustomCRM\Timezone\AutomationTimezoneHandler() )->register(); - ( new \CustomCRM\Timezone\SequenceTimezoneHandler() )->register(); - ( new \CustomCRM\Timezone\TimezoneDetector() )->register(); - // Contact and company enrichment via external providers (PDL, etc.). ( new \CustomCRM\Actions\EnrichContactAction() ); },