'Brevo contacts', 'mailerlite' => 'MailerLite subscribers' ) ); } public static function defaults() { return array( 'enabled' => false, 'acknowledged' => false, 'fields' => array(), 'url' => '', 'secret' => '', 'email' => '', 'first_name' => '', 'last_name' => '', 'consent' => '', 'list_id' => 0, 'revision' => '' ); } public static function sanitize( $raw, $form, $destination ) { if ( ! in_array( $destination, array( 'brevo', 'mailerlite' ), true ) ) { return apply_filters( 'kconv_integration_sanitize', new WP_Error( 'kconv_pro_destination', __( 'This saved destination requires matching Pro. Its settings and jobs are retained.', 'killerconversions' ) ), $raw, $form, $destination ); } if ( ! is_array( $raw ) || ! isset( self::destinations()[ $destination ] ) ) { return new WP_Error( 'kconv_destination', __( 'Invalid delivery settings.', 'killerconversions' ) ); } $clean = self::defaults(); foreach ( array( 'enabled', 'acknowledged', 'clear_secret', 'clear_url' ) as $flag ) { if ( isset( $raw[ $flag ] ) && ! in_array( $raw[ $flag ], array( true, false, 0, 1, '0', '1', '' ), true ) ) { return new WP_Error( 'kconv_destination', __( 'Delivery switches must be valid checkbox values.', 'killerconversions' ) ); } } $clean['enabled'] = ! empty( $raw['enabled'] ); $clean['acknowledged'] = ! empty( $raw['acknowledged'] ); $fields = array_column( $form['fields'], null, 'id' ); foreach ( array( 'url', 'secret', 'email', 'first_name', 'last_name', 'consent', 'revision' ) as $key ) { if ( isset( $raw[ $key ] ) && ! is_string( $raw[ $key ] ) ) { return new WP_Error( 'kconv_destination', __( 'Delivery values must be text.', 'killerconversions' ) ); } $clean[ $key ] = isset( $raw[ $key ] ) ? trim( $raw[ $key ] ) : ''; } $selected = isset( $raw['fields'] ) ? $raw['fields'] : array(); if ( ! is_array( $selected ) || count( $selected ) > 50 ) { return new WP_Error( 'kconv_mapping', __( 'Choose a valid set of delivery fields.', 'killerconversions' ) ); } foreach ( $selected as $id ) { if ( ! is_string( $id ) || ! isset( $fields[ $id ] ) || in_array( $fields[ $id ]['type'], array( 'file', 'repeater' ), true ) ) { if ( $clean['enabled'] ) { return new WP_Error( 'kconv_mapping', __( 'A delivery mapping refers to a removed or private file field. Choose an answer field before saving.', 'killerconversions' ) ); } continue; } $clean['fields'][] = $id; } $clean['fields'] = array_values( array_unique( $clean['fields'] ) ); sort( $clean['fields'] ); if ( isset( $raw['list_id'] ) && ( ! is_scalar( $raw['list_id'] ) || ! preg_match( '/^\d{1,10}$/D', (string) $raw['list_id'] ) ) ) { return new WP_Error( 'kconv_mapping', __( 'Brevo list ID must be a positive number.', 'killerconversions' ) ); } $clean['list_id'] = isset( $raw['list_id'] ) ? (int) $raw['list_id'] : 0; if ( strlen( $clean['url'] ) > 2000 || strlen( $clean['secret'] ) > ( 'mailerlite' === $destination ? 2048 : 512 ) || ( '' !== $clean['secret'] && preg_match( '/[^\x21-\x7e]/', $clean['secret'] ) ) ) { return new WP_Error( 'kconv_credentials', __( 'Invalid destination or credential format.', 'killerconversions' ) ); } if ( class_exists( 'KCONV_Newsletter_Adapter' ) && KCONV_Newsletter_Adapter::supported( $destination ) ) { return KCONV_Newsletter_Adapter::sanitize( $clean, $raw, $form, $destination ); } if ( ! $clean['enabled'] ) { return $clean; } if ( ! $clean['acknowledged'] ) { return new WP_Error( 'kconv_consent', __( 'Acknowledge the destination and selected personal data before enabling delivery.', 'killerconversions' ) ); } $clean['url'] = 'https://api.brevo.com/v3/contacts'; if ( strlen( $clean['secret'] ) < 20 || $clean['list_id'] < 1 || $clean['list_id'] > 2147483647 ) { return new WP_Error( 'kconv_credentials', __( 'Brevo requires an API key and a positive list ID.', 'killerconversions' ) ); } if ( ! isset( $fields[ $clean['email'] ] ) || 'email' !== $fields[ $clean['email'] ]['type'] ) { return new WP_Error( 'kconv_mapping', __( 'Map Brevo email to an email field.', 'killerconversions' ) ); } if ( ! isset( $fields[ $clean['consent'] ] ) || 'checkbox' !== $fields[ $clean['consent'] ]['type'] || ! empty( $fields[ $clean['consent'] ]['required'] ) ) { return new WP_Error( 'kconv_consent', __( 'Brevo requires a separate optional checkbox for subscriber/marketing consent. It must not be required to submit the form.', 'killerconversions' ) ); } $clean['fields'] = array( $clean['email'] ); foreach ( array( 'first_name', 'last_name' ) as $key ) { if ( '' === $clean[ $key ] ) { continue; } if ( ! isset( $fields[ $clean[ $key ] ] ) || ! in_array( $fields[ $clean[ $key ] ]['type'], array( 'text', 'select', 'radio' ), true ) ) { return new WP_Error( 'kconv_mapping', __( 'Map Brevo names to text or choice fields.', 'killerconversions' ) ); } $clean['fields'][] = $clean[ $key ]; } $clean['fields'] = array_values( array_unique( $clean['fields'] ) ); sort( $clean['fields'] ); return $clean; } public static function config( $form, $destination ) { $all = get_post_meta( $form['id'], '_kconv_integrations', true ); $config = self::sanitize( isset( $all[ $destination ] ) ? $all[ $destination ] : array(), $form, $destination ); if ( is_wp_error( $config ) || ! preg_match( '/^[a-f0-9-]{36}$/D', $config['revision'] ) ) { return is_wp_error( $config ) ? $config : self::defaults(); } // Bind the generation to every effective setting, even if another extension edits metadata directly. $config['revision'] = hash( 'sha256', wp_json_encode( $config ) ); if ( 'brevo' === $destination ) { $config['consent_digest'] = self::consent_digest( $form, $config ); } if ( 'mailerlite' === $destination && class_exists( 'KCONV_Newsletter_Adapter' ) ) { $config['consent_digest'] = KCONV_Newsletter_Adapter::consent_digest( $form, $config ); } return $config; } public static function consent_digest( $form, $config ) { foreach ( $form['fields'] as $field ) { if ( $field['id'] === $config['consent'] ) { return hash_hmac( 'sha256', wp_json_encode( array( $config['email'], (int) $config['list_id'], $field['id'], $field['type'], $field['label'], $field['help'], $field['required'] ) ), wp_salt( 'auth' ) ); } } return ''; } public static function from_post( $form, $post ) { $old = get_post_meta( $form['id'], '_kconv_integrations', true ); if ( isset( $post['kconv_integrations'] ) && ! is_array( $post['kconv_integrations'] ) ) { return new WP_Error( 'kconv_destination', __( 'Delivery settings are malformed. Reload the editor.', 'killerconversions' ) ); } $inputs = isset( $post['kconv_integrations'] ) && is_array( $post['kconv_integrations'] ) ? $post['kconv_integrations'] : array(); $result = is_array( $old ) ? $old : array(); foreach ( self::destinations() as $key => $label ) { if ( isset( $inputs[ $key ] ) && ! is_array( $inputs[ $key ] ) ) { return new WP_Error( 'kconv_destination', __( 'Delivery settings are malformed. Reload the editor.', 'killerconversions' ) ); } $raw = isset( $inputs[ $key ] ) && is_array( $inputs[ $key ] ) ? $inputs[ $key ] : array(); if ( 'hubspot' === $key && ! empty( $old[ $key ]['properties'] ) && ! array_key_exists( 'properties', $raw ) ) { return new WP_Error( 'kconv_mapping', __( 'This editor is missing the saved HubSpot properties. Reload before saving.', 'killerconversions' ) ); } foreach ( array( 'secret', 'url' ) as $credential ) { // URL is a credential too (Slack, signed endpoint query strings). Never echo stored URLs. if ( empty( $raw[ 'clear_' . $credential ] ) && ( ! isset( $raw[ $credential ] ) || '' === $raw[ $credential ] ) && isset( $old[ $key ][ $credential ] ) ) { $raw[ $credential ] = $old[ $key ][ $credential ]; } if ( ! empty( $raw[ 'clear_' . $credential ] ) ) { $raw[ $credential ] = ''; } } $clean = self::sanitize( $raw, $form, $key ); if ( is_wp_error( $clean ) ) { return new WP_Error( 'kconv_integration_' . $key, $label . ': ' . $clean->get_error_message() ); } $previous = isset( $old[ $key ] ) && is_array( $old[ $key ] ) ? $old[ $key ] : self::defaults(); $revision = isset( $previous['revision'] ) ? $previous['revision'] : ''; $previous['revision'] = ''; $clean['revision'] = ''; $clean['revision'] = $clean === $previous && preg_match( '/^[a-f0-9-]{36}$/D', $revision ) ? $revision : wp_generate_uuid4(); $result[ $key ] = $clean; } return $result; } public static function validate_save( $errors, $form, $post ) { if ( ! isset( $post['kconv_integrations_present'] ) ) { // Schema-only saves must still respect saved, enabled mappings. foreach ( self::destinations() as $key => $label ) { $config = self::config( $form, $key ); if ( is_wp_error( $config ) ) { $errors->add( 'kconv_integration_' . $key, $label . ': ' . $config->get_error_message() ); } } return $errors; } if ( ! current_user_can( 'manage_options' ) ) { $errors->add( 'kconv_integration_permission', __( 'Reload this page before saving delivery settings.', 'killerconversions' ) ); return $errors; } if ( empty( $post['kconv_integrations_nonce'] ) || ! is_string( $post['kconv_integrations_nonce'] ) ) { $errors->add( 'kconv_integration_permission', __( 'Reload this page before saving delivery settings.', 'killerconversions' ) ); return $errors; } if ( ! wp_verify_nonce( sanitize_text_field( $post['kconv_integrations_nonce'] ), 'kconv_integrations_save' ) ) { $errors->add( 'kconv_integration_permission', __( 'Reload this page before saving delivery settings.', 'killerconversions' ) ); return $errors; } if ( ! isset( $post['kconv_integrations_complete'] ) || ! is_string( $post['kconv_integrations_complete'] ) || '1' !== $post['kconv_integrations_complete'] ) { $errors->add( 'kconv_integration_incomplete', __( 'Delivery settings are incomplete or this editor was opened before an update. Reload the editor and try again. If this repeats, ask your host to review the PHP input limit. Existing settings have not been changed.', 'killerconversions' ) ); return $errors; } $config = self::from_post( $form, $post ); if ( is_wp_error( $config ) ) { $errors->add( $config->get_error_code(), $config->get_error_message() ); } return $errors; } public static function save( $form_id, $form, $post ) { if ( ! isset( $post['kconv_integrations_present'] ) || ! isset( $post['kconv_integrations_complete'] ) || ! is_string( $post['kconv_integrations_complete'] ) || '1' !== $post['kconv_integrations_complete'] ) { return; } if ( ! current_user_can( 'manage_options' ) ) { return; } if ( empty( $post['kconv_integrations_nonce'] ) || ! is_string( $post['kconv_integrations_nonce'] ) ) { return; } if ( ! wp_verify_nonce( sanitize_text_field( $post['kconv_integrations_nonce'] ), 'kconv_integrations_save' ) ) { return; } $config = self::from_post( $form, $post ); if ( is_wp_error( $config ) ) { return; } update_post_meta( $form_id, '_kconv_integrations', wp_slash( $config ) ); foreach ( $config as $destination => $settings ) { $effective = self::config( $form, $destination ); KCONV_Delivery::cancel_changed( $form_id, $destination, $settings['enabled'] && ! is_wp_error( $effective ) ? $effective['revision'] : '' ); } if ( class_exists( 'KCONV_Free_Ownership' ) ) { KCONV_Free_Ownership::refresh_form( $form_id ); } } /** Only scalar answers and flat choice lists may leave the site; file metadata stays private even after schema changes. */ public static function transferable( $value ) { if ( is_scalar( $value ) ) { return true; } return is_array( $value ) && ( array() === $value || array_keys( $value ) === range( 0, count( $value ) - 1 ) ) && count( array_filter( $value, 'is_scalar' ) ) === count( $value ); } /** Payload is built from the immutable entry, only at send time; never copied into logs/jobs. */ public static function request( $destination, $config, $entry, $delivery_id ) { if ( ! in_array( $destination, array( 'brevo', 'mailerlite' ), true ) ) { return apply_filters( 'kconv_integration_request', new WP_Error( 'kconv_pro_destination', __( 'Matching Pro is required for this destination.', 'killerconversions' ) ), $destination, $config, $entry, $delivery_id ); } $values = json_decode( $entry['payload'], true ); if ( ! is_array( $values ) ) { return new WP_Error( 'invalid_payload', __( 'Stored entry is invalid.', 'killerconversions' ) ); } $values = array_filter( $values, array( __CLASS__, 'transferable' ) ); if ( class_exists( 'KCONV_Newsletter_Adapter' ) && KCONV_Newsletter_Adapter::supported( $destination ) ) { return KCONV_Newsletter_Adapter::request( $destination, $config, $values ); } $selected = array_intersect_key( $values, array_flip( $config['fields'] ) ); $headers = array(); if ( 'brevo' === $destination ) { if ( ! isset( $values[ $config['consent'] ] ) || '1' !== (string) $values[ $config['consent'] ] ) { return new WP_Error( 'consent_missing', __( 'No subscriber consent on this entry.', 'killerconversions' ) ); } if ( empty( $selected[ $config['email'] ] ) || ! is_string( $selected[ $config['email'] ] ) || ! is_email( $selected[ $config['email'] ] ) ) { return new WP_Error( 'email_missing', __( 'The mapped email is absent on this entry.', 'killerconversions' ) ); } $payload = array( 'email' => $selected[ $config['email'] ], 'listIds' => array( $config['list_id'] ), 'updateEnabled' => true ); foreach ( array( 'first_name' => 'FIRSTNAME', 'last_name' => 'LASTNAME' ) as $key => $attribute ) { if ( $config[ $key ] && isset( $selected[ $config[ $key ] ] ) && is_scalar( $selected[ $config[ $key ] ] ) && '' !== $selected[ $config[ $key ] ] ) { $payload['attributes'][ $attribute ] = $selected[ $config[ $key ] ]; } } // Do not send emailBlacklisted/smsBlacklisted: never reset an existing unsubscribe. $headers['api-key'] = $config['secret']; } else { return new WP_Error( 'invalid_destination', __( 'Unknown destination.', 'killerconversions' ) ); } $body = wp_json_encode( $payload, JSON_UNESCAPED_SLASHES ); if ( ! is_string( $body ) || strlen( $body ) > 131072 ) { return new WP_Error( 'invalid_payload', __( 'Payload cannot be encoded within the size limit.', 'killerconversions' ) ); } return array( 'url' => $config['url'], 'body' => $body, 'headers' => $headers, 'secret' => '' ); } private static function select( $name, $value, $form, $type = '' ) { echo ''; } public static function send( $destination, $request, $job ) { if ( 'mailerlite' === $destination ) { return KCONV_Newsletter_Adapter::send( $destination, $request ); } if ( 'brevo' === $destination && 'https://api.brevo.com/v3/contacts' === $request['url'] ) { return KCONV_Newsletter_Transport::request( $request['url'], $request['body'], $request['headers'] ); } return apply_filters( 'kconv_integration_send', array( 'status' => 'destination_unavailable', 'http_code' => 0 ), $destination, $request, $job ); } public static function admin_fields( $form ) { if ( ! current_user_can( 'manage_options' ) ) { return; } wp_enqueue_script( 'kconv-delivery-admin', KCONV_URL . 'assets/delivery.js', array( 'kconv-admin' , 'wp-i18n' ), KCONV_VERSION, true ); wp_set_script_translations( 'kconv-delivery-admin', 'killerconversions', KCONV_DIR . 'languages' ); wp_enqueue_style( 'kconv-connection-review', KCONV_URL . 'assets/connection-review.css', array( 'kconv-workspace' ), KCONV_VERSION ); echo '

' . esc_html__( 'Optional newsletter connections', 'killerconversions' ) . '

' . esc_html__( 'A submission never implies a subscription. Send only when the visitor checks a separate optional marketing-consent box. Review destinations before enabling; saving does not test provider receipt.', 'killerconversions' ) . '

'; wp_nonce_field( 'kconv_integrations_save', 'kconv_integrations_nonce' ); foreach ( array( 'brevo' => 'Brevo', 'mailerlite' => 'MailerLite' ) as $key => $label ) { $config = self::config( $form, $key ); if ( is_wp_error( $config ) ) { echo '

' . esc_html( $config->get_error_message() ) . '

'; $config = array_merge( self::defaults(), array( 'group_id' => '' ) ); } $prefix = 'kconv_integrations[' . $key . ']'; echo '
' . esc_html( $label ) . '

' . esc_html( 'brevo' === $key ? __( 'Email and selected names go to api.brevo.com, adding consenting contacts to one list. This can start your automations; no double opt-in is performed and unsubscribe flags are not reset.', 'killerconversions' ) : __( 'Email and selected names go to connect.mailerlite.com, adding consenting subscribers to one group. Automations may run; resubscribe is false and existing subscription status is not overridden. MailerLite Classic is unsupported.', 'killerconversions' ) ) . '

'; echo '

'; echo '

'; foreach ( array( 'email' => __( 'Email question', 'killerconversions' ), 'first_name' => __( 'First name (optional)', 'killerconversions' ), 'last_name' => __( 'Last name (optional)', 'killerconversions' ), 'consent' => __( 'Separate optional marketing consent', 'killerconversions' ) ) as $mapping => $text ) { echo '

'; } $list = 'brevo' === $key ? 'list_id' : 'group_id'; echo '

'; } do_action( 'kconv_integrations_premium_editor', $form ); echo '
'; } }