diff --git a/includes/class-newspack-newsletters-ads.php b/includes/class-newspack-newsletters-ads.php index 2ace23217..53b435154 100644 --- a/includes/class-newspack-newsletters-ads.php +++ b/includes/class-newspack-newsletters-ads.php @@ -75,21 +75,11 @@ public static function rest_api_init() { [ 'callback' => [ __CLASS__, 'get_ads_config' ], 'methods' => 'GET', - 'permission_callback' => [ __CLASS__, 'permission_callback' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], ] ); } - /** - * Check capabilities for using the API for authoring tasks. - * - * @param WP_REST_Request $request API request object. - * @return bool|WP_Error - */ - public static function permission_callback( $request ) { - return current_user_can( 'edit_posts' ); - } - /** * Register custom fields. */ diff --git a/includes/class-newspack-newsletters-editor.php b/includes/class-newspack-newsletters-editor.php index 5eaa336fe..526966095 100644 --- a/includes/class-newspack-newsletters-editor.php +++ b/includes/class-newspack-newsletters-editor.php @@ -49,6 +49,7 @@ public function __construct() { add_action( 'the_post', [ __CLASS__, 'strip_editor_modifications' ] ); add_action( 'after_setup_theme', [ __CLASS__, 'newspack_font_sizes' ], 11 ); add_action( 'enqueue_block_editor_assets', [ __CLASS__, 'enqueue_block_editor_assets' ] ); + add_filter( 'block_categories_all', [ __CLASS__, 'add_custom_block_category' ] ); add_filter( 'allowed_block_types_all', [ __CLASS__, 'newsletters_allowed_block_types' ], 10, 2 ); add_action( 'rest_post_query', [ __CLASS__, 'maybe_filter_excerpt_length' ], 10, 2 ); add_action( 'rest_post_query', [ __CLASS__, 'maybe_exclude_sponsored_posts' ], 10, 2 ); @@ -249,6 +250,24 @@ public static function newspack_font_sizes() { ); } + /** + * Add the "Newspack" block category. + * + * @param array $block_categories Default block categories. + * @return array + */ + public static function add_custom_block_category( $block_categories ) { + array_unshift( + $block_categories, + [ + 'slug' => 'newspack', + 'title' => 'Newspack', + ] + ); + + return $block_categories; + } + /** * Restrict block types for Newsletter CPT. * @@ -302,14 +321,9 @@ public static function enqueue_block_editor_assets() { $mjml_handling_post_types = array_values( array_diff( self::get_email_editor_cpts(), [ Newspack_Newsletters_Ads::CPT ] ) ); $provider = Newspack_Newsletters::get_service_provider(); $conditional_tag_support = false; - $error_message = false; if ( $provider && ( self::is_editing_newsletter() || self::is_editing_newsletter_ad() ) ) { $conditional_tag_support = $provider::get_conditional_tag_support(); - - // Fetch async error messages to display on editor load. - $transient_name = $provider->get_transient_name( get_the_ID() ); - $error_message = get_transient( $transient_name ); } $email_editor_data = [ @@ -324,12 +338,9 @@ public static function enqueue_block_editor_assets() { 'byline_connector_label' => __( 'and ', 'newspack-newsletters' ), ], 'supported_social_icon_services' => Newspack_Newsletters_Renderer::get_supported_social_icons_services(), + 'supported_esps' => Newspack_Newsletters::get_supported_providers(), ]; - if ( $error_message ) { - $email_editor_data['error_message'] = $error_message; - } - if ( self::is_editing_email() ) { wp_register_style( 'newspack-newsletters', @@ -371,6 +382,7 @@ public static function enqueue_block_editor_assets() { 'is_service_provider_configured' => Newspack_Newsletters::is_service_provider_configured(), 'service_provider' => Newspack_Newsletters::service_provider(), 'user_test_emails' => self::get_current_user_test_emails(), + 'labels' => $provider ? $provider::get_labels() : [], ] ); wp_register_style( diff --git a/includes/class-newspack-newsletters-layouts.php b/includes/class-newspack-newsletters-layouts.php index bae1c9dd4..5f66c8e9a 100644 --- a/includes/class-newspack-newsletters-layouts.php +++ b/includes/class-newspack-newsletters-layouts.php @@ -77,7 +77,7 @@ public static function register_meta() { \register_meta( 'post', 'font_body', $meta_default_params ); \register_meta( 'post', 'background_color', $meta_default_params ); \register_meta( 'post', 'custom_css', $meta_default_params ); - \register_meta( 'post', 'layout_defaults', $meta_default_params ); + \register_meta( 'post', 'campaign_defaults', $meta_default_params ); } /** @@ -191,5 +191,68 @@ public static function get_default_layouts() { } return $layouts; } + + /** + * Get all layouts. + */ + public static function get_layouts() { + $layouts_query = new WP_Query( + [ + 'post_type' => self::NEWSPACK_NEWSLETTERS_LAYOUT_CPT, + 'posts_per_page' => -1, + ] + ); + $user_layouts = array_map( + function ( $post ) { + $post->meta = [ + 'background_color' => get_post_meta( $post->ID, 'background_color', true ), + 'font_body' => get_post_meta( $post->ID, 'font_body', true ), + 'font_header' => get_post_meta( $post->ID, 'font_header', true ), + 'custom_css' => get_post_meta( $post->ID, 'custom_css', true ), + 'campaign_defaults' => get_post_meta( $post->ID, 'campaign_defaults', true ), + ]; + + // Migrate layout defaults from legacy meta, if it exists. + $campaign_defaults = $post->meta['campaign_defaults']; + $legacy_meta = json_decode( get_post_meta( $post->ID, 'layout_defaults', true ), true ); + if ( empty( $campaign_defaults ) && ! empty( $legacy_meta ) ) { + $campaign_defaults = []; + if ( ! empty( $legacy_meta['senderName'] ) ) { + $campaign_defaults['senderName'] = $legacy_meta['senderName']; + } + if ( ! empty( $legacy_meta['senderEmail'] ) ) { + $campaign_defaults['senderEmail'] = $legacy_meta['senderEmail']; + } + $provider = Newspack_Newsletters::get_service_provider(); + $campaign_info = $provider->extract_campaign_info( $legacy_meta['newsletterData'] ?? null ); + if ( ! empty( $campaign_info['list_id'] ) ) { + $campaign_defaults['send_list_id'] = $campaign_info['list_id']; + } + if ( ! empty( $campaign_info['sublist_id'] ) ) { + $campaign_defaults['send_sublist_id'] = $campaign_info['sublist_id']; + } + if ( ! empty( $campaign_info['senderName'] ) ) { + $campaign_defaults['senderName'] = $campaign_info['senderName']; + } + if ( ! empty( $campaign_info['senderEmail'] ) ) { + $campaign_defaults['senderEmail'] = $campaign_info['senderEmail']; + } + if ( ! empty( $campaign_defaults ) ) { + $campaign_defaults = wp_json_encode( $campaign_defaults ); + update_post_meta( $post->ID, 'campaign_defaults', $campaign_defaults ); + $post->meta['campaign_defaults'] = $campaign_defaults; + } + } + + return $post; + }, + $layouts_query->get_posts() + ); + return array_merge( + $user_layouts, + self::get_default_layouts(), + apply_filters( 'newspack_newsletters_templates', [] ) + ); + } } Newspack_Newsletters_Layouts::instance(); diff --git a/includes/class-newspack-newsletters-settings.php b/includes/class-newspack-newsletters-settings.php index 3090694f1..968b3f778 100644 --- a/includes/class-newspack-newsletters-settings.php +++ b/includes/class-newspack-newsletters-settings.php @@ -576,7 +576,7 @@ public static function update_option_newspack_newsletters_public_posts_slug( $ol /** * Update settings. * - * @param string $settings Update. + * @param array $settings Update. */ public static function update_settings( $settings ) { foreach ( $settings as $key => $value ) { diff --git a/includes/class-newspack-newsletters-subscription.php b/includes/class-newspack-newsletters-subscription.php index 928783632..865d5f4f6 100644 --- a/includes/class-newspack-newsletters-subscription.php +++ b/includes/class-newspack-newsletters-subscription.php @@ -14,9 +14,6 @@ * Manages Settings Subscription Class. */ class Newspack_Newsletters_Subscription { - - const API_NAMESPACE = 'newspack-newsletters/v1'; - const EMAIL_VERIFIED_META = 'newspack_newsletters_email_verified'; const EMAIL_VERIFIED_REQUEST = 'newspack_newsletters_email_verification_request'; const EMAIL_VERIFIED_CONFIRM = 'newspack_newsletters_email_verification'; @@ -62,7 +59,7 @@ public static function init() { */ public static function register_api_endpoints() { register_rest_route( - self::API_NAMESPACE, + Newspack_Newsletters::API_NAMESPACE, '/lists_config', [ 'methods' => \WP_REST_Server::READABLE, @@ -71,21 +68,21 @@ public static function register_api_endpoints() { ] ); register_rest_route( - self::API_NAMESPACE, + Newspack_Newsletters::API_NAMESPACE, '/lists', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ __CLASS__, 'api_get_lists' ], - 'permission_callback' => [ __CLASS__, 'api_permission_callback' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_administration_permissions_check' ], ] ); register_rest_route( - self::API_NAMESPACE, + Newspack_Newsletters::API_NAMESPACE, '/lists', [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ __CLASS__, 'api_update_lists' ], - 'permission_callback' => [ __CLASS__, 'api_permission_callback' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_administration_permissions_check' ], 'args' => [ 'lists' => [ 'type' => 'array', @@ -113,15 +110,6 @@ public static function register_api_endpoints() { ); } - /** - * Whether the current user can manage subscription lists. - * - * @return bool Whether the current user can manage subscription lists. - */ - public static function api_permission_callback() { - return current_user_can( 'manage_options' ); - } - /** * API method to retrieve the current lists configuration. * @@ -229,7 +217,7 @@ function ( $list ) { public static function get_lists_config() { $provider = Newspack_Newsletters::get_service_provider(); if ( empty( $provider ) ) { - return new WP_Error( 'newspack_newsletters_invalid_provider', __( 'Provider is not set.' ) ); + return new WP_Error( 'newspack_newsletters_invalid_provider', __( 'Provider is not set.', 'newspack-newsletters' ) ); } $saved_lists = Subscription_Lists::get_configured_for_current_provider(); @@ -262,11 +250,11 @@ public static function get_lists_config() { public static function update_lists( $lists ) { $provider = Newspack_Newsletters::get_service_provider(); if ( empty( $provider ) ) { - return new WP_Error( 'newspack_newsletters_invalid_provider', __( 'Provider is not set.' ) ); + return new WP_Error( 'newspack_newsletters_invalid_provider', __( 'Provider is not set.', 'newspack-newsletters' ) ); } $lists = self::sanitize_lists( $lists ); if ( empty( $lists ) ) { - return new WP_Error( 'newspack_newsletters_invalid_lists', __( 'Invalid list configuration.' ) ); + return new WP_Error( 'newspack_newsletters_invalid_lists', __( 'Invalid list configuration.', 'newspack-newsletters' ) ); } return Subscription_Lists::update_lists( $lists ); @@ -321,16 +309,16 @@ public static function has_subscription_management() { */ public static function get_contact_data( $email_address, $return_details = false ) { if ( ! $email_address || empty( $email_address ) ) { - return new WP_Error( 'newspack_newsletters_invalid_email', __( 'Missing email address.' ) ); + return new WP_Error( 'newspack_newsletters_invalid_email', __( 'Missing email address.', 'newspack-newsletters' ) ); } $provider = Newspack_Newsletters::get_service_provider(); if ( empty( $provider ) ) { - return new WP_Error( 'newspack_newsletters_invalid_provider', __( 'Provider is not set.' ) ); + return new WP_Error( 'newspack_newsletters_invalid_provider', __( 'Provider is not set.', 'newspack-newsletters' ) ); } if ( ! method_exists( $provider, 'get_contact_data' ) ) { - return new WP_Error( 'newspack_newsletters_not_implemented', __( 'Provider does not handle the contact-exists check.' ) ); + return new WP_Error( 'newspack_newsletters_not_implemented', __( 'Provider does not handle the contact-exists check.', 'newspack-newsletters' ) ); } return $provider->get_contact_data( $email_address, $return_details ); diff --git a/includes/class-newspack-newsletters.php b/includes/class-newspack-newsletters.php index 3d85e677f..8b3ccedd1 100644 --- a/includes/class-newspack-newsletters.php +++ b/includes/class-newspack-newsletters.php @@ -18,6 +18,7 @@ final class Newspack_Newsletters { const EMAIL_HTML_META = 'newspack_email_html'; const NEWSPACK_NEWSLETTERS_PALETTE_META = 'newspack_newsletters_color_palette'; const PUBLIC_POST_ID_META = 'newspack_nl_public_post_id'; + const API_NAMESPACE = 'newspack-newsletters/v1'; /** * Supported fonts. @@ -121,7 +122,7 @@ public static function get_supported_providers() { // Remove support for Campaign Monitor if we don't have the required environment flag. if ( 'campaign_monitor' !== self::service_provider() && ( ! defined( 'NEWSPACK_NEWSLETTERS_SUPPORT_DEPRECATED_CAMPAIGN_MONITOR' ) || ! NEWSPACK_NEWSLETTERS_SUPPORT_DEPRECATED_CAMPAIGN_MONITOR ) ) { - $supported_providers = array_diff( $supported_providers, [ 'campaign_monitor' ] ); + $supported_providers = array_values( array_diff( $supported_providers, [ 'campaign_monitor' ] ) ); } return $supported_providers; @@ -181,29 +182,7 @@ public static function register_editor_only_meta() { ]; $fields = [ [ - 'name' => 'newsletterData', - 'register_meta_args' => [ - 'show_in_rest' => [ - 'schema' => [ - 'type' => 'object', - 'context' => [ 'edit' ], - 'additionalProperties' => true, - 'properties' => [], - ], - ], - 'type' => 'object', - ], - ], - [ - 'name' => 'senderName', - 'register_meta_args' => $default_register_meta_args, - ], - [ - 'name' => 'senderEmail', - 'register_meta_args' => $default_register_meta_args, - ], - [ - 'name' => 'stringifiedLayoutDefaults', + 'name' => 'stringifiedCampaignDefaults', 'register_meta_args' => $default_register_meta_args, ], [ @@ -266,6 +245,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -284,6 +264,70 @@ public static function register_meta() { 'default' => -1, ] ); + \register_meta( + 'post', + 'send_list_id', + [ + 'object_subtype' => self::NEWSPACK_NEWSLETTERS_CPT, + 'show_in_rest' => [ + 'schema' => [ + 'context' => [ 'edit' ], + ], + ], + 'type' => 'string', + 'single' => true, + 'auth_callback' => '__return_true', + 'default' => '', + ] + ); + \register_meta( + 'post', + 'send_sublist_id', + [ + 'object_subtype' => self::NEWSPACK_NEWSLETTERS_CPT, + 'show_in_rest' => [ + 'schema' => [ + 'context' => [ 'edit' ], + ], + ], + 'type' => 'string', + 'single' => true, + 'auth_callback' => '__return_true', + 'default' => '', + ] + ); + \register_meta( + 'post', + 'senderName', + [ + 'object_subtype' => self::NEWSPACK_NEWSLETTERS_CPT, + 'show_in_rest' => [ + 'schema' => [ + 'context' => [ 'edit' ], + ], + ], + 'type' => 'string', + 'single' => true, + 'auth_callback' => '__return_true', + 'default' => '', + ] + ); + \register_meta( + 'post', + 'senderEmail', + [ + 'object_subtype' => self::NEWSPACK_NEWSLETTERS_CPT, + 'show_in_rest' => [ + 'schema' => [ + 'context' => [ 'edit' ], + ], + ], + 'type' => 'string', + 'single' => true, + 'auth_callback' => '__return_true', + 'default' => '', + ] + ); \register_meta( 'post', 'newsletter_sent', @@ -313,6 +357,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -328,6 +373,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -343,6 +389,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -358,6 +405,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -373,6 +421,7 @@ public static function register_meta() { 'type' => 'boolean', 'single' => true, 'auth_callback' => '__return_true', + 'default' => false, ] ); \register_meta( @@ -634,7 +683,7 @@ public static function disable_jetpack_related_posts( $options ) { */ public static function rest_api_init() { \register_rest_route( - 'newspack-newsletters/v1', + self::API_NAMESPACE, 'layouts', [ 'methods' => \WP_REST_Server::READABLE, @@ -643,7 +692,7 @@ public static function rest_api_init() { ] ); \register_rest_route( - 'newspack-newsletters/v1', + self::API_NAMESPACE, 'settings', [ 'methods' => \WP_REST_Server::READABLE, @@ -652,7 +701,7 @@ public static function rest_api_init() { ] ); \register_rest_route( - 'newspack-newsletters/v1', + self::API_NAMESPACE, 'settings', [ 'methods' => \WP_REST_Server::EDITABLE, @@ -666,7 +715,7 @@ public static function rest_api_init() { ] ); \register_rest_route( - 'newspack-newsletters/v1', + self::API_NAMESPACE, 'color-palette', [ 'methods' => \WP_REST_Server::EDITABLE, @@ -676,7 +725,7 @@ public static function rest_api_init() { ); \register_rest_route( - 'newspack-newsletters/v1', + self::API_NAMESPACE, 'post-mjml', [ 'methods' => \WP_REST_Server::EDITABLE, @@ -759,32 +808,7 @@ public static function validate_newsletter_id( $id ) { * Retrieve Layouts. */ public static function api_get_layouts() { - $layouts_query = new WP_Query( - [ - 'post_type' => Newspack_Newsletters_Layouts::NEWSPACK_NEWSLETTERS_LAYOUT_CPT, - 'posts_per_page' => -1, - ] - ); - $user_layouts = array_map( - function ( $post ) { - $post->meta = [ - 'background_color' => get_post_meta( $post->ID, 'background_color', true ), - 'font_body' => get_post_meta( $post->ID, 'font_body', true ), - 'font_header' => get_post_meta( $post->ID, 'font_header', true ), - 'custom_css' => get_post_meta( $post->ID, 'custom_css', true ), - 'layout_defaults' => get_post_meta( $post->ID, 'layout_defaults', true ), - ]; - return $post; - }, - $layouts_query->get_posts() - ); - $layouts = array_merge( - $user_layouts, - Newspack_Newsletters_Layouts::get_default_layouts(), - apply_filters( 'newspack_newsletters_templates', [] ) - ); - - return \rest_ensure_response( $layouts ); + return \rest_ensure_response( Newspack_Newsletters_Layouts::get_layouts() ); } /** @@ -834,6 +858,15 @@ public static function api_set_settings( $request ) { return $wp_error->has_errors() ? $wp_error : self::api_get_settings(); } + /** + * Whether the current user can manage admin settings. + * + * @return bool Whether the current user can manage admin settings. + */ + public static function api_permission_callback() { + return current_user_can( 'manage_options' ); + } + /** * Retrieve settings. */ diff --git a/includes/class-send-list.php b/includes/class-send-list.php new file mode 100644 index 000000000..be487042f --- /dev/null +++ b/includes/class-send-list.php @@ -0,0 +1,339 @@ + $property ) { + // If the property is required but not set, throw an error. + if ( ! empty( $property['required'] ) && ! isset( $config[ $key ] ) ) { + $errors->add( 'newspack_newsletters_send_list_invalid_config', __( 'Missing required property: ', 'newspack-newsletters' ) . $key ); + continue; + } + + // No need to continue if an optional key isn't set. + if ( ! isset( $config[ $key ] ) ) { + continue; + } + + // Set the property. + $result = $this->set( $key, $config[ $key ] ); + if ( \is_wp_error( $result ) ) { + $result->export_to( $errors ); + } + } + + // Throw an exception if there are errors. + if ( $errors->has_errors() ) { + throw new \InvalidArgumentException( esc_html( __( 'Error creating send list: ', 'newspack-newsletters' ) . implode( '. ', $errors->get_error_messages() ) ) ); + } + } + + /** + * Get the config data schema for a single Send_List. + * See __construct() method docblock for details. + * + * @return array + */ + public static function get_config_schema() { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + 'provider' => [ + 'name' => 'provider', + 'type' => 'string', + 'required' => true, + 'enum' => Newspack_Newsletters::get_supported_providers(), + ], + 'type' => [ + 'name' => 'type', + 'type' => 'string', + 'required' => true, + 'enum' => [ + 'list', + 'sublist', + ], + ], + 'entity_type' => [ + 'name' => 'entity_type', + 'type' => 'string', + 'required' => true, + ], + 'id' => [ + 'name' => 'id', + 'type' => 'string', + 'required' => true, + ], + 'value' => [ + 'name' => 'value', + 'type' => 'string', + 'required' => false, + ], + 'name' => [ + 'name' => 'name', + 'type' => 'string', + 'required' => true, + ], + 'label' => [ + 'name' => 'label', + 'type' => 'string', + 'required' => false, + ], + 'local_name' => [ + 'name' => 'local_name', + 'type' => 'string', + 'required' => false, + ], + 'count' => [ + 'name' => 'count', + 'type' => 'integer', + 'required' => false, + ], + 'parent_id' => [ + 'name' => 'parent_id', + 'type' => 'string', + 'required' => false, + ], + 'edit_link' => [ + 'name' => 'edit_link', + 'type' => 'string', + 'required' => false, + ], + ], + ]; + } + + /** + * Helper method to get a specific property's value. + * Not for public use. Use specific property getters instead. + * + * @param string $key The property to get. + * + * @return mixed|WP_Error The property value or null if not set/not a supported property. + */ + private function get( $key ) { + $value = $this->config[ $key ] ?? null; + $schema = $this->get_config_schema(); + if ( ! empty( $schema['properties'][ $key ]['required'] ) && empty( $this->config[ $key ] ) ) { + return new WP_Error( 'newspack_newsletters_send_list_missing_required_property_' . $key, __( 'Could not get required property: ', 'newspack-newsletters' ) . $key ); + } + + return $value; + } + + /** + * Helper method to set a property's value. + * Not for public use. Use specific property setters instead. + * + * @param string $key The property to get. + * @param mixed $value The value to set. + * + * @return mixed|WP_Error The property value or WP_Error if not set/not a supported property. + */ + private function set( $key, $value ) { + $schema = $this->get_config_schema(); + if ( ! isset( $schema['properties'][ $key ] ) ) { + return new WP_Error( 'newspack_newsletters_send_list_invalid_property_' . $key, __( 'Could not set invalid property: ', 'newspack-newsletters' ) . $key ); + } + + // If the passed value isn't in the enum, throw an error. + $property = $schema['properties'][ $key ]; + if ( isset( $property['enum'] ) && ! in_array( $value, $property['enum'], true ) ) { + return new WP_Error( 'newspack_newsletters_send_list_invalid_property_value_' . $key, __( 'Invalid value for property: ', 'newspack-newsletters' ) . $key ); + } + + // Cast value to the expected type. + settype( $value, $property['type'] ); + + $this->config[ $key ] = $value; + return $this->get( $key ); + } + + /** + * Get the provider for this Send_List. + */ + public function get_provider() { + return $this->get( 'provider' ); + } + + /** + * Get the ID for this Send_List. + */ + public function get_id() { + return $this->get( 'id' ); + } + + /** + * Get the parent list's ID for this Send_List. + */ + public function get_parent_id() { + return $this->get( 'parent_id' ); + } + + /** + * Get the type for this Send_List: list or sublist. + */ + public function get_type() { + return $this->get( 'type' ); + } + + /** + * Get the entity type for this Send_List. + */ + public function get_entity_type() { + return $this->get( 'entity_type' ); + } + + /** + * Get the name for this Send_List. + */ + public function get_name() { + return $this->get( 'name' ); + } + + /** + * Update the name for this Send_List. + * + * @param string $value The new name. + */ + public function set_name( $value ) { + return $this->set( 'name', $value ); + } + + /** + * Get the local name for this Send_List, if the entity is also a Subscription_List. + */ + public function get_local_name() { + return $this->get( 'local_name' ); + } + + /** + * Update the local name for this Send_List, if the entity is also a Subscription_List. + * + * @param string $value The new name. + */ + public function set_local_name( $value ) { + return $this->set( 'local_name', $value ); + } + + /** + * Get the contact count for this send list. + */ + public function get_count() { + return $this->get( 'count' ); + } + + /** + * Update the contact count for this send list. + * + * @param string $value The new name. + */ + public function set_count( $value ) { + return $this->set( 'count', $value ); + } + + /** + * Get a manually set or dynamic label for autocomplete inputs. + * If not passed via __construct(), will be generated from entity_type, name, and count. + * + * @return string + */ + public function get_label() { + $stored_label = $this->get( 'label' ); + if ( ! empty( $stored_label ) ) { + return $stored_label; + } + + $entity_type = '[' . strtoupper( $this->get( 'entity_type' ) ) . ']'; + $count = $this->get( 'count' ); + $name = $this->get( 'name' ); + + $contact_count = null !== $count ? + sprintf( + // Translators: If available, show a contact count alongside the suggested item. %d is the number of contacts in the suggested item. + _n( '(%s contact)', '(%s contacts)', $count, 'newspack-newsletters' ), + number_format( $count ) + ) : ''; + + return trim( "$entity_type $name $contact_count" ); + } + + /** + * Get the value for autocomplete inputs. + * If not passed via __construct(), defaults to the id. + * + * @return string + */ + public function get_value() { + return $this->get( 'value' ) ?? $this->get( 'id' ); + } + + /** + * Convert the Send_List to an array for use with the REST API. + * + * @return array + */ + public function to_array() { + $config = $this->config; + + // Ensure label + value properties are set for JS components. + $config['label'] = $this->get_label(); + $config['value'] = $this->get_value(); + + return $config; + } +} diff --git a/includes/class-send-lists.php b/includes/class-send-lists.php new file mode 100644 index 000000000..5cc300c00 --- /dev/null +++ b/includes/class-send-lists.php @@ -0,0 +1,174 @@ + \WP_REST_Server::READABLE, + 'callback' => [ __CLASS__, 'api_get_send_lists' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], + 'args' => [ + 'ids' => [ + 'type' => [ 'array', 'string' ], + ], + 'search' => [ + 'type' => [ 'array', 'string' ], + ], + 'type' => [ + 'type' => 'string', + ], + 'parent_id' => [ + 'type' => 'string', + ], + 'provider' => [ + 'type' => 'string', + ], + 'limit' => [ + 'type' => [ 'integer', 'string' ], + ], + ], + ] + ); + } + + /** + * Get default arguments for the send lists API. Supported keys; + * + * - ids: ID or array of send IDs to fetch. If passed, will take precedence over `search`. + * - search: Search term or array of search terms to filter send lists. If `ids` is passed, will be ignored. + * - type: Type of send list to filter. Supported terms are 'list' or 'sublist', otherwise all types will be fetched. + * - parent_id: Parent ID to filter by when fetching sublists. If `type` is 'list`, will be ignored. + * - limit: Limit the number of send lists to return. + * + * @return array + */ + public static function get_default_args() { + return [ + 'ids' => null, + 'search' => null, + 'type' => null, + 'parent_id' => null, + 'limit' => null, + ]; + } + + /** + * Check if an ID or array of IDs to search matches the given ID. + * + * @param array|string $ids ID or array of IDs to search. + * @param string $id ID to match against. + * + * @return boolean + */ + public static function matches_id( $ids, $id ) { + if ( is_array( $ids ) ) { + return in_array( $id, $ids, false ); // phpcs:ignore WordPress.PHP.StrictInArray.FoundNonStrictFalse + } + return (string) $id === (string) $ids; + } + + /** + * Check if the given search term matches any of the given strings. + * + * @param null|array|string $search Search term or array of terms. If null, return true. + * @param array $matches An array of strings to match against. + * + * @return boolean + */ + public static function matches_search( $search, $matches = [] ) { + if ( null === $search ) { + return true; + } + if ( ! is_array( $search ) ) { + $search = [ $search ]; + } + foreach ( $search as $to_match ) { + // Don't try to match values that will convert to empty strings, or that we can't convert to a string. + if ( ! $to_match || is_array( $to_match ) ) { + continue; + } + $to_match = strtolower( strval( $to_match ) ); + foreach ( $matches as $match ) { + if ( stripos( strtolower( strval( $match ) ), $to_match ) !== false ) { + return true; + } + } + } + return false; + } + + /** + * API handler to fetch send lists for the given provider. + * Send_List objects are converted to arrays of config data before being returned. + * + * @param WP_REST_Request $request Full details about the request. + * + * @return WP_REST_Response|WP_Error WP_REST_Response on success, or WP_Error object on failure. + */ + public static function api_get_send_lists( $request ) { + $provider_slug = $request['provider'] ?? null; + $provider = $provider_slug ? Newspack_Newsletters::get_service_provider_instance( $provider_slug ) : Newspack_Newsletters::get_service_provider(); + if ( empty( $provider ) ) { + return new WP_Error( 'newspack_newsletters_invalid_provider', __( 'Invalid provider, or provider not set.', 'newspack-newsletters' ) ); + } + + $defaults = self::get_default_args(); + $args = []; + foreach ( $defaults as $key => $value ) { + $args[ $key ] = $request[ $key ] ?? $value; + } + return \rest_ensure_response( $provider->get_send_lists( $args, true ) ); + } +} diff --git a/includes/service-providers/active_campaign/class-newspack-newsletters-active-campaign-controller.php b/includes/service-providers/active_campaign/class-newspack-newsletters-active-campaign-controller.php index 3c8172c93..61d382298 100644 --- a/includes/service-providers/active_campaign/class-newspack-newsletters-active-campaign-controller.php +++ b/includes/service-providers/active_campaign/class-newspack-newsletters-active-campaign-controller.php @@ -104,7 +104,7 @@ public function register_routes() { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'api_retrieve' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], 'args' => [ 'id' => [ 'sanitize_callback' => 'absint', @@ -119,7 +119,7 @@ public function register_routes() { [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'api_test' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], 'args' => [ 'id' => [ 'sanitize_callback' => 'absint', diff --git a/includes/service-providers/active_campaign/class-newspack-newsletters-active-campaign.php b/includes/service-providers/active_campaign/class-newspack-newsletters-active-campaign.php index a0205a5f6..b6598ec8b 100644 --- a/includes/service-providers/active_campaign/class-newspack-newsletters-active-campaign.php +++ b/includes/service-providers/active_campaign/class-newspack-newsletters-active-campaign.php @@ -7,11 +7,21 @@ defined( 'ABSPATH' ) || exit; +use Newspack\Newsletters\Send_Lists; +use Newspack\Newsletters\Send_List; + /** * ActiveCampaign ESP Class. */ final class Newspack_Newsletters_Active_Campaign extends \Newspack_Newsletters_Service_Provider { + /** + * Provider name. + * + * @var string + */ + public $name = 'ActiveCampaign'; + /** * Cached fields. * @@ -26,6 +36,13 @@ final class Newspack_Newsletters_Active_Campaign extends \Newspack_Newsletters_S */ private $lists = null; + /** + * Cached segments. + * + * @var array + */ + private $segments = null; + /** * Cached contact data. * @@ -40,13 +57,6 @@ final class Newspack_Newsletters_Active_Campaign extends \Newspack_Newsletters_S */ public static $support_local_lists = true; - /** - * Provider name. - * - * @var string - */ - public $name = 'ActiveCampaign'; - /** * Class constructor. */ @@ -181,9 +191,10 @@ public function api_v1_request( $action, $method = 'GET', $options = [] ) { } $body = json_decode( $response['body'], true ); if ( 1 !== $body['result_code'] ) { + $message = ! empty( $body['result_message'] ) ? $body['result_message'] : __( 'An error occurred while communicating with ActiveCampaign.', 'newspack-newsletters' ); return new \WP_Error( 'newspack_newsletters_active_campaign_api_error', - $body['result_message'] + $message ); } return $body; @@ -494,13 +505,39 @@ public function set_api_credentials( $credentials ) { /** * Get lists. * + * @param array $args Query args to pass to the lists_lists endpoint. + * For supported args, see: https://www.activecampaign.com/api/example.php?call=list_list. + * * @return array|WP_Error List of existing lists or error. */ - public function get_lists() { + public function get_lists( $args = [] ) { if ( null !== $this->lists ) { + if ( ! empty( $args['ids'] ) ) { + return array_values( + array_filter( + $this->lists, + function ( $list ) use ( $args ) { + return Send_Lists::matches_id( $args['ids'], $list['id'] ); + } + ) + ); + } + if ( ! empty( $args['filters[name]'] ) ) { + return array_values( + array_filter( + $this->lists, + function ( $list ) use ( $args ) { + return Send_Lists::matches_search( $args['filters[name]'], [ $list['name'] ] ); + } + ) + ); + } return $this->lists; } - $lists = $this->api_v1_request( 'list_list', 'GET', [ 'query' => [ 'ids' => 'all' ] ] ); + if ( empty( $args['ids'] ) && empty( $args['filters[name]'] ) ) { + $args['ids'] = 'all'; + } + $lists = $this->api_v1_request( 'list_list', 'GET', [ 'query' => $args ] ); if ( is_wp_error( $lists ) ) { return $lists; } @@ -508,43 +545,169 @@ public function get_lists() { unset( $lists['result_code'] ); unset( $lists['result_message'] ); unset( $lists['result_output'] ); - $this->lists = array_values( $lists ); - return $this->lists; + + if ( ! empty( $args['ids'] ) && 'all' === $args['ids'] ) { + $this->lists = array_values( $lists ); + } + return array_values( $lists ); + } + + /** + * Get all applicable lists and segments as Send_List objects. + * + * @param array $args Array of search args. See Send_Lists::get_default_args() for supported params and default values. + * @param boolean $to_array If true, convert Send_List objects to arrays before returning. + * + * @return Send_List[]|array|WP_Error Array of Send_List objects or arrays on success, or WP_Error object on failure. + */ + public function get_send_lists( $args = [], $to_array = false ) { + $send_lists = []; + if ( empty( $args['type'] ) || 'list' === $args['type'] ) { + $list_args = [ + 'limit' => ! empty( $args['limit'] ) ? intval( $args['limit'] ) : 100, + ]; + + // Search by IDs. + if ( ! empty( $args['ids'] ) ) { + $list_args['ids'] = implode( ',', $args['ids'] ); + } + + // Search by name. + if ( ! empty( $args['search'] ) ) { + if ( is_array( $args['search'] ) ) { + return new WP_Error( + 'newspack_newsletters_active_campaign_fetch_send_lists', + __( 'ActiveCampaign supports searching by a single search term only.', 'newspack-newsletters' ) + ); + } + $list_args['filters[name]'] = $args['search']; + } + + $lists = $this->get_lists( $list_args ); + if ( is_wp_error( $lists ) ) { + return $lists; + } + foreach ( $lists as $list ) { + $send_lists[] = new Send_List( + [ + 'provider' => $this->service, + 'type' => 'list', + 'id' => $list['id'], + 'name' => $list['name'], + 'entity_type' => 'list', + 'count' => $list['subscriber_count'] ?? 0, + ] + ); + } + } + + if ( empty( $args['type'] ) || 'sublist' === $args['type'] ) { + $segment_args = []; + if ( ! empty( $args['ids'] ) ) { + $segment_args['ids'] = $args['ids']; + } + if ( ! empty( $args['search'] ) ) { + $segment_args['search'] = $args['search']; + } + $segments = $this->get_segments( $segment_args ); + if ( is_wp_error( $segments ) ) { + return $segments; + } + foreach ( $segments as $segment ) { + $segment_name = ! empty( $segment['name'] ) ? + $segment['name'] . ' (ID ' . $segment['id'] . ')' : + sprintf( + // Translators: %s is the segment ID. + __( 'Untitled %s', 'newspack-newsletters' ), + $segment['id'] + ); + $send_lists[] = new Send_List( + [ + 'provider' => $this->service, + 'type' => 'sublist', + 'id' => $segment['id'], + 'parent' => $args['parent'] ?? null, + 'name' => $segment_name, + 'entity_type' => 'segment', + 'count' => $segment['subscriber_count'] ?? null, + ] + ); + } + } + + // Convert to arrays if requested. + if ( $to_array ) { + $send_lists = array_map( + function ( $list ) { + return $list->to_array(); + }, + $send_lists + ); + } + return $send_lists; } /** * Get segments. * + * @param array $args Array of search args. + * * @return array|WP_Error List os existing segments or error. */ - public function get_segments() { - $limit = 100; - $offset = 0; + public function get_segments( $args = [] ) { + if ( null !== $this->segments ) { + if ( ! empty( $args['ids'] ) ) { + $filtered = array_values( + array_filter( + $this->segments, + function ( $segment ) use ( $args ) { + return Send_Lists::matches_id( $args['ids'], $segment['id'] ); + } + ) + ); + return array_slice( $filtered, 0, $args['limit'] ?? count( $filtered ) ); + } + if ( ! empty( $args['search'] ) ) { + $filtered = array_values( + array_filter( + $this->segments, + function ( $segment ) use ( $args ) { + return Send_Lists::matches_search( $args['search'], [ $segment['name'] ] ); + } + ) + ); + return array_slice( $filtered, 0, $args['limit'] ?? count( $filtered ) ); + } + return $this->segments; + } + + $query_args = $args; + $query_args['limit'] = $args['limit'] ?? 100; + $query_args['offset'] = 0; $result = $this->api_v3_request( 'segments', 'GET', [ - 'query' => [ - 'limit' => $limit, - 'offset' => $offset, - ], + 'query' => $query_args, ] ); if ( is_wp_error( $result ) ) { return $result; } $segments = $result['segments']; - $total = $result['meta']['total']; - while ( $total > $offset + $limit ) { - $offset = $offset + $limit; + if ( isset( $args['limit'] ) ) { + return $segments; + } + + // If not passed a limit, get all the segments. + $total = $result['meta']['total']; + while ( $total > $query_args['offset'] + $query_args['limit'] ) { + $query_args['offset'] = $query_args['offset'] + $query_args['limit']; $result = $this->api_v3_request( 'segments', 'GET', [ - 'query' => [ - 'limit' => $limit, - 'offset' => $offset, - ], + 'query' => $query_args, ] ); if ( is_wp_error( $result ) ) { @@ -552,7 +715,13 @@ public function get_segments() { } $segments = array_merge( $segments, $result['segments'] ); } - return $segments; + + $this->segments = $segments; + if ( ! empty( $args['ids'] ) || ! empty( $args['search'] ) ) { + return $this->get_segments( $args ); + } + + return $this->segments; } /** @@ -565,51 +734,150 @@ public function list( $post_id, $list_id ) { return null; } + /** + * Given legacy newsletterData, extract sender and send-to info. + * + * @param array $newsletter_data Newsletter data from the ESP. + * @return array { + * Extracted sender and send-to info. All keys are optional and will be + * returned only if found in the campaign data. + * + * @type string $senderName Sender name. + * @type string $senderEmail Sender email. + * @type string $list_id List ID. + * @type string $sublist_id Sublist ID. + * } + */ + public function extract_campaign_info( $newsletter_data ) { + $campaign_info = []; + + // Sender info. + if ( ! empty( $newsletter_data['from_name'] ) ) { + $campaign_info['senderName'] = $newsletter_data['from_name']; + } + if ( ! empty( $newsletter_data['from_email'] ) ) { + $campaign_info['senderEmail'] = $newsletter_data['from_email']; + } + + // List. + if ( ! empty( $newsletter_data['list_id'] ) ) { + $campaign_info['list_id'] = $newsletter_data['list_id']; + } + + // Segment. + if ( ! empty( $newsletter_data['segment_id'] ) ) { + $campaign_info['sublist_id'] = $newsletter_data['segment_id']; + } + + return $campaign_info; + } + /** * Retrieve a campaign. * * @param int $post_id Numeric ID of the Newsletter post. * @param bool $skip_sync Whether to skip syncing the campaign. + * @throws Exception Error message. * @return array|WP_Error API Response or error. */ public function retrieve( $post_id, $skip_sync = false ) { - if ( ! $this->has_api_credentials() ) { - return []; - } - $lists = $this->get_lists(); - if ( is_wp_error( $lists ) ) { - return $lists; - } - $segments = $this->get_segments(); - if ( is_wp_error( $segments ) ) { - return $segments; - } - $campaign_id = get_post_meta( $post_id, 'ac_campaign_id', true ); - $from_name = get_post_meta( $post_id, 'ac_from_name', true ); - $from_email = get_post_meta( $post_id, 'ac_from_email', true ); - $list_id = get_post_meta( $post_id, 'ac_list_id', true ); - $segment_id = get_post_meta( $post_id, 'ac_segment_id', true ); - $result = [ - 'campaign' => true, // Satisfy the JS API. - 'campaign_id' => $campaign_id, - 'from_name' => $from_name, - 'from_email' => $from_email, - 'list_id' => $list_id, - 'segment_id' => $segment_id, - 'lists' => $lists, - 'segments' => $segments, - ]; - if ( ! $campaign_id && true !== $skip_sync ) { - $sync_result = $this->sync( get_post( $post_id ) ); - if ( ! is_wp_error( $sync_result ) ) { - $result = wp_parse_args( + try { + if ( ! $this->has_api_credentials() ) { + throw new Exception( esc_html__( 'Missing or invalid ActiveCampign credentials.', 'newspack-newsletters' ) ); + } + + $campaign_id = get_post_meta( $post_id, 'ac_campaign_id', true ); + $send_list_id = get_post_meta( $post_id, 'send_list_id', true ); + $send_sublist_id = get_post_meta( $post_id, 'send_sublist_id', true ); + + // Handle legacy send-to meta. + if ( ! $send_list_id ) { + $legacy_list_id = get_post_meta( $post_id, 'ac_list_id', true ); + if ( $legacy_list_id ) { + $newsletter_data['list_id'] = $legacy_list_id; + $send_list_id = $legacy_list_id; + } + } + if ( ! $send_sublist_id ) { + $legacy_sublist_id = get_post_meta( $post_id, 'ac_segment_id', true ); + if ( $legacy_sublist_id ) { + $newsletter_data['sublist_id'] = $legacy_sublist_id; + $send_sublist_id = $legacy_sublist_id; + } + } + $send_lists = $this->get_send_lists( // Get first 10 top-level send lists for autocomplete. + [ + 'ids' => $send_list_id ? [ $send_list_id ] : null, // If we have a selected list, make sure to fetch it. + 'type' => 'list', + ], + true + ); + if ( is_wp_error( $send_lists ) ) { + throw new Exception( wp_kses_post( $send_lists->get_error_message() ) ); + } + $send_sublists = $send_list_id || $send_sublist_id ? + $this->get_send_lists( + [ + 'ids' => [ $send_sublist_id ], // If we have a selected sublist, make sure to fetch it. Otherwise, we'll populate sublists later. + 'parent_id' => $send_list_id, + 'type' => 'sublist', + ], + true + ) : + []; + if ( is_wp_error( $send_sublists ) ) { + throw new Exception( wp_kses_post( $send_sublists->get_error_message() ) ); + } + $newsletter_data = [ + 'campaign' => true, // Satisfy the JS API. + 'campaign_id' => $campaign_id, + 'supports_multiple_test_recipients' => true, + 'lists' => $send_lists, + 'sublists' => $send_sublists, + ]; + + if ( $campaign_id ) { + $newsletter_data['link'] = sprintf( + 'https://%s.activehosted.com/app/campaigns/%d', + explode( '.', str_replace( 'https://', '', $this->api_credentials()['url'] ) )[0], + $campaign_id + ); + } + + // Handle legacy sender meta. + $from_name = get_post_meta( $post_id, 'senderName', true ); + $from_email = get_post_meta( $post_id, 'senderEmail', true ); + if ( ! $from_name ) { + $legacy_from_name = get_post_meta( $post_id, 'ac_from_name', true ); + if ( $legacy_from_name ) { + $newsletter_data['senderName'] = $legacy_from_name; + } + } + if ( ! $from_email ) { + $legacy_from_email = get_post_meta( $post_id, 'ac_from_email', true ); + if ( $legacy_from_email ) { + $newsletter_data['senderEmail'] = $legacy_from_email; + } + } + + if ( ! $campaign_id && true !== $skip_sync ) { + $sync_result = $this->sync( get_post( $post_id ) ); + if ( is_wp_error( $sync_result ) ) { + throw new Exception( $sync_result->get_error_message() ); + } + $newsletter_data = wp_parse_args( $sync_result, - $result + $newsletter_data ); } + return $newsletter_data; + } catch ( Exception $e ) { + return new WP_Error( + 'newspack_newsletters_active_campaign_error', + $e->getMessage() + ); } - return $result; } /** @@ -674,7 +942,7 @@ public function test( $post_id, $emails ) { if ( is_wp_error( $campaign_data ) ) { return $campaign_data; } - $campaign_messages = explode( ',', $campaign_data[0]['messageslist'] ); + $campaign_messages = explode( ',', $campaign_data[0]['messageslist'] ); $message_id = ! empty( $campaign_messages ) ? reset( $campaign_messages ) : 0; $test_result = $this->api_v1_request( @@ -731,17 +999,19 @@ public function sync( $post ) { $transient_name = $this->get_transient_name( $post->ID ); delete_transient( $transient_name ); - $from_name = get_post_meta( $post->ID, 'ac_from_name', true ); - $from_email = get_post_meta( $post->ID, 'ac_from_email', true ); - $list_id = get_post_meta( $post->ID, 'ac_list_id', true ); - $is_public = get_post_meta( $post->ID, 'is_public', true ); - $message_id = get_post_meta( $post->ID, 'ac_message_id', true ); + $from_name = get_post_meta( $post->ID, 'senderName', true ); + $from_email = get_post_meta( $post->ID, 'senderEmail', true ); + $send_list_id = get_post_meta( $post->ID, 'send_list_id', true ); + $message_id = get_post_meta( $post->ID, 'ac_message_id', true ); $renderer = new Newspack_Newsletters_Renderer(); $content = $renderer->retrieve_email_html( $post ); $message_action = 'message_add'; $message_data = []; + $sync_data = [ + 'campaign' => true, // Satisfy JS API. + ]; if ( $message_id ) { $message = $this->api_v1_request( 'message_view', 'GET', [ 'query' => [ 'id' => $message_id ] ] ); @@ -753,10 +1023,8 @@ public function sync( $post ) { // If sender data is not available locally, update from ESP. if ( ! $from_name || ! $from_email ) { - $from_name = $message['fromname']; - $from_email = $message['fromemail']; - update_post_meta( $post->ID, 'ac_from_name', $from_name ); - update_post_meta( $post->ID, 'ac_from_email', $from_email ); + $sync_data['senderName'] = $message['fromname']; + $sync_data['senderEmail'] = $message['fromemail']; } } else { // Validate required meta if campaign and message are not yet created. @@ -766,7 +1034,7 @@ public function sync( $post ) { __( 'Please input sender name and email address.', 'newspack-newsletters' ) ); } - if ( empty( $list_id ) ) { + if ( empty( $send_list_id ) ) { return new \WP_Error( 'newspack_newsletters_active_campaign_invalid_list', __( 'Please select a list.', 'newspack-newsletters' ) @@ -776,13 +1044,13 @@ public function sync( $post ) { $message_data = wp_parse_args( [ - 'format' => 'html', - 'htmlconstructor' => 'editor', - 'html' => $content, - 'p[' . $list_id . ']' => 1, - 'fromemail' => $from_email, - 'fromname' => $from_name, - 'subject' => $post->post_title, + 'format' => 'html', + 'htmlconstructor' => 'editor', + 'html' => $content, + 'p[' . $send_list_id . ']' => 1, + 'fromemail' => $from_email, + 'fromname' => $from_name, + 'subject' => $post->post_title, ], $message_data ); @@ -793,14 +1061,7 @@ public function sync( $post ) { } update_post_meta( $post->ID, 'ac_message_id', $message['id'] ); - - $sync_data = [ - 'campaign' => true, // Satisfy JS API. - 'message_id' => $message['id'], - 'list_id' => $list_id, - 'from_email' => $from_email, - 'from_name' => $from_name, - ]; + $sync_data['message_id'] = $message['id']; // Retrieve and store campaign data. $data = $this->retrieve( $post->ID, true ); @@ -809,7 +1070,6 @@ public function sync( $post ) { return $data; } else { $data = array_merge( $data, $sync_data ); - update_post_meta( $post->ID, 'newsletterData', $data ); } return $sync_data; @@ -828,8 +1088,13 @@ private function create_campaign( $post, $campaign_name = '' ) { if ( is_wp_error( $sync_result ) ) { return $sync_result; } - $segment_id = get_post_meta( $post->ID, 'ac_segment_id', true ); - $is_public = get_post_meta( $post->ID, 'is_public', true ); + + $from_name = get_post_meta( $post->ID, 'senderName', true ); + $from_email = get_post_meta( $post->ID, 'senderEmail', true ); + $send_list_id = get_post_meta( $post->ID, 'send_list_id', true ); + $send_sublist_id = get_post_meta( $post->ID, 'send_sublist_id', true ); + + $is_public = get_post_meta( $post->ID, 'is_public', true ); if ( empty( $campaign_name ) ) { $campaign_name = $this->get_campaign_name( $post ); } @@ -838,10 +1103,10 @@ private function create_campaign( $post, $campaign_name = '' ) { 'status' => 0, // 0 = Draft; 1 = Scheduled. 'public' => (int) $is_public, 'name' => $campaign_name, - 'fromname' => $sync_result['from_name'], - 'fromemail' => $sync_result['from_email'], - 'segmentid' => $segment_id ?? 0, // 0 = No segment. - 'p[' . $sync_result['list_id'] . ']' => $sync_result['list_id'], + 'fromname' => $from_name, + 'fromemail' => $from_email, + 'segmentid' => $send_sublist_id ?? 0, // 0 = No segment. + 'p[' . $send_list_id . ']' => $send_list_id, 'm[' . $sync_result['message_id'] . ']' => 100, // 100 = 100% of contacts will receive this. ]; if ( defined( 'NEWSPACK_NEWSLETTERS_AC_DISABLE_LINK_TRACKING' ) && NEWSPACK_NEWSLETTERS_AC_DISABLE_LINK_TRACKING ) { @@ -1352,6 +1617,12 @@ public static function get_labels( $context = '' ) { 'name' => 'Active Campaign', 'list_explanation' => __( 'Active Campaign List', 'newspack-newsletters' ), 'local_list_explanation' => __( 'Active Campaign Tag', 'newspack-newsletters' ), + 'list' => __( 'list', 'newspack-newsletters' ), // "list" in lower case singular format. + 'lists' => __( 'lists', 'newspack-newsletters' ), // "list" in lower case plural format. + 'sublist' => __( 'segment', 'newspack-newsletters' ), // Sublist entities in lowercase singular format. + 'List' => __( 'List', 'newspack-newsletters' ), // "list" in uppercase case singular format. + 'Lists' => __( 'Lists', 'newspack-newsletters' ), // "list" in uppercase case plural format. + 'Sublist' => __( 'Segments', 'newspack-newsletters' ), // Sublist entities in uppercase singular format. ] ); } diff --git a/includes/service-providers/campaign_monitor/class-newspack-newsletters-campaign-monitor-controller.php b/includes/service-providers/campaign_monitor/class-newspack-newsletters-campaign-monitor-controller.php index dabce22ea..ba25966fe 100644 --- a/includes/service-providers/campaign_monitor/class-newspack-newsletters-campaign-monitor-controller.php +++ b/includes/service-providers/campaign_monitor/class-newspack-newsletters-campaign-monitor-controller.php @@ -18,107 +18,10 @@ class Newspack_Newsletters_Campaign_Monitor_Controller extends Newspack_Newslett */ public function __construct( $campaign_monitor ) { $this->service_provider = $campaign_monitor; - add_action( 'init', [ __CLASS__, 'register_meta' ] ); add_action( 'rest_api_init', [ $this, 'register_routes' ] ); parent::__construct( $campaign_monitor ); } - /** - * Register custom fields. - */ - public static function register_meta() { - \register_meta( - 'post', - 'cm_list_id', - [ - 'object_subtype' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, - 'show_in_rest' => [ - 'schema' => [ - 'context' => [ 'edit' ], - ], - ], - 'type' => 'string', - 'single' => true, - 'auth_callback' => '__return_true', - ] - ); - \register_meta( - 'post', - 'cm_segment_id', - [ - 'object_subtype' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, - 'show_in_rest' => [ - 'schema' => [ - 'context' => [ 'edit' ], - ], - ], - 'type' => 'string', - 'single' => true, - 'auth_callback' => '__return_true', - ] - ); - \register_meta( - 'post', - 'cm_send_mode', - [ - 'object_subtype' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, - 'show_in_rest' => [ - 'schema' => [ - 'context' => [ 'edit' ], - ], - ], - 'type' => 'string', - 'single' => true, - 'auth_callback' => '__return_true', - ] - ); - \register_meta( - 'post', - 'cm_from_name', - [ - 'object_subtype' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, - 'show_in_rest' => [ - 'schema' => [ - 'context' => [ 'edit' ], - ], - ], - 'type' => 'string', - 'single' => true, - 'auth_callback' => '__return_true', - ] - ); - \register_meta( - 'post', - 'cm_from_email', - [ - 'object_subtype' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, - 'show_in_rest' => [ - 'schema' => [ - 'context' => [ 'edit' ], - ], - ], - 'type' => 'string', - 'single' => true, - 'auth_callback' => '__return_true', - ] - ); - \register_meta( - 'post', - 'cm_preview_text', - [ - 'object_subtype' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, - 'show_in_rest' => [ - 'schema' => [ - 'context' => [ 'edit' ], - ], - ], - 'type' => 'string', - 'single' => true, - 'auth_callback' => '__return_true', - ] - ); - } - /** * Register API endpoints unique to Campaign Monitor. */ @@ -134,7 +37,7 @@ public function register_routes() { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'api_retrieve' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], 'args' => [ 'id' => [ 'sanitize_callback' => 'absint', @@ -143,22 +46,13 @@ public function register_routes() { ], ] ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/content', - [ - 'methods' => \WP_REST_Server::READABLE, - 'callback' => [ $this, 'api_content' ], - 'permission_callback' => '__return_true', - ] - ); \register_rest_route( $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, '(?P[\a-z]+)/test', [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'api_test' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], 'args' => [ 'id' => [ 'sanitize_callback' => 'absint', @@ -201,25 +95,4 @@ public function api_test( $request ) { ); return self::get_api_response( $response ); } - - /** - * Get raw HTML for a campaign. Required for the Campaign Monitor API. - * - * @param WP_REST_Request $request API request object. - * @return void|WP_Error - */ - public function api_content( $request ) { - $response = $this->service_provider->content( - $request['public_id'] - ); - - if ( is_wp_error( $response ) ) { - return self::get_api_response( $response ); - } - - header( 'Content-Type: text/html; charset=UTF-8' ); - - echo $response; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped - exit(); - } } diff --git a/includes/service-providers/campaign_monitor/class-newspack-newsletters-campaign-monitor.php b/includes/service-providers/campaign_monitor/class-newspack-newsletters-campaign-monitor.php index ada28dfe9..ee69e5511 100644 --- a/includes/service-providers/campaign_monitor/class-newspack-newsletters-campaign-monitor.php +++ b/includes/service-providers/campaign_monitor/class-newspack-newsletters-campaign-monitor.php @@ -7,6 +7,9 @@ defined( 'ABSPATH' ) || exit; +use Newspack\Newsletters\Send_Lists; +use Newspack\Newsletters\Send_List; + // Increase default timeout for 3rd-party API requests to 30s. define( 'CS_REST_CALL_TIMEOUT', 30 ); @@ -22,6 +25,21 @@ final class Newspack_Newsletters_Campaign_Monitor extends \Newspack_Newsletters_ */ public $name = 'Campaign Monitor'; + /** + * Cached lists. + * + * @var array + */ + private $lists = null; + + /** + * Cached segments. + * + * @var array + */ + private $segments = null; + + /** * Class constructor. */ @@ -115,9 +133,13 @@ public function set_api_credentials( $credentials ) { /** * Get lists for a client iD. * - * @return object|WP_Error API API Response or error. + * @return array|WP_Error Array of lists, or error. */ public function get_lists() { + if ( null !== $this->lists ) { + return $this->lists; + } + $api_key = $this->api_key(); $client_id = $this->client_id(); @@ -145,7 +167,7 @@ public function get_lists() { ); } - return array_map( + $lists = array_map( function ( $item ) { return [ 'id' => $item->ListID, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase @@ -154,14 +176,21 @@ function ( $item ) { }, $lists->response ); + + $this->lists = $lists; + return $this->lists; } /** * Get segments for a client iD. * - * @return object|WP_Error API API Response or error. + * @return array|WP_Error Array of segments, or error. */ public function get_segments() { + if ( null !== $this->segments ) { + return $this->segments; + } + $api_key = $this->api_key(); $client_id = $this->client_id(); @@ -189,46 +218,193 @@ public function get_segments() { ); } - return $segments->response; + $segments = array_map( + function ( $item ) { + return [ + 'id' => $item->SegmentID, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'name' => $item->Title, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + 'parent' => $item->ListID, // phpcs:ignore WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase + ]; + }, + $segments->response + ); + + $this->segments = $segments; + return $this->segments; + } + + /** + * Get all applicable lists and segments as Send_List objects. + * Note that in CM, campaigns can be sent to either lists or segments, not both, + * so both entity types should be treated as top-level send lists. + * + * @param array $args Array of search args. See Send_Lists::get_default_args() for supported params and default values. + * @param boolean $to_array If true, convert Send_List objects to arrays before returning. + * + * @return Send_List[]|array|WP_Error Array of Send_List objects or arrays on success, or WP_Error object on failure. + */ + public function get_send_lists( $args = [], $to_array = false ) { + $api_key = $this->api_key(); + $client_id = $this->client_id(); + + if ( ! $api_key ) { + return new WP_Error( + 'newspack_newsletters_missing_api_key', + __( 'No Campaign Monitor API key available.', 'newspack-newsletters' ) + ); + } + if ( ! $client_id ) { + return new WP_Error( + 'newspack_newsletters_missing_client_id', + __( 'No Campaign Monitor Client ID available.', 'newspack-newsletters' ) + ); + } + + $lists = $this->get_lists(); + if ( is_wp_error( $lists ) ) { + return $lists; + } + $send_lists = array_map( + function( $list ) use ( $api_key ) { + $config = [ + 'provider' => $this->service, + 'type' => 'list', + 'id' => $list['id'], + 'name' => $list['name'], + 'entity_type' => 'list', + ]; + + $list_details = new CS_REST_Lists( $list['id'], [ 'api_key' => $api_key ] ); + $list_stats = $list_details->get_stats(); + if ( ! empty( $list_stats->response->TotalActiveSubscribers ) ) { + $config['count'] = $list_stats->response->TotalActiveSubscribers; + } + + return new Send_List( $config ); + }, + $lists + ); + $segments = $this->get_segments(); + if ( is_wp_error( $segments ) ) { + return $segments; + } + $send_segments = array_map( + function( $segment ) { + $segment_id = (string) $segment['id']; + $config = [ + 'provider' => $this->service, + 'type' => 'list', // In CM, segments and lists have the same hierarchy. + 'id' => $segment_id, + 'name' => $segment['name'], + 'entity_type' => 'segment', + ]; + + return new Send_List( $config ); + }, + $segments + ); + $send_lists = array_merge( $send_lists, $send_segments ); + $filtered_lists = $send_lists; + if ( ! empty( $args['ids'] ) ) { + $ids = ! is_array( $args['ids'] ) ? [ $args['ids'] ] : $args['ids']; + $filtered_lists = array_values( + array_filter( + $send_lists, + function ( $list ) use ( $ids ) { + return Send_Lists::matches_id( $ids, $list->get_id() ); + } + ) + ); + } + if ( ! empty( $args['search'] ) ) { + $search = ! is_array( $args['search'] ) ? [ $args['search'] ] : $args['search']; + $filtered_lists = array_values( + array_filter( + $send_lists, + function ( $list ) use ( $search ) { + return Send_Lists::matches_search( + $search, + [ + $list->get_id(), + $list->get_name(), + $list->get_entity_type(), + ] + ); + } + ) + ); + } + + if ( ! empty( $args['limit'] ) ) { + $filtered_lists = array_slice( $filtered_lists, 0, $args['limit'] ); + } + + // Convert to arrays if requested. + if ( $to_array ) { + $filtered_lists = array_map( + function ( $list ) { + return $list->to_array(); + }, + $filtered_lists + ); + } + return $filtered_lists; } /** * Retrieve campaign details. * * @param integer $post_id Numeric ID of the Newsletter post. - * @param boolean $fetch_all If true, returns all campaign data, even those stored in WP. * @return object|WP_Error API Response or error. + * @throws Exception Error message. */ - public function retrieve( $post_id, $fetch_all = false ) { - if ( ! $this->has_api_credentials() ) { - return []; - } + public function retrieve( $post_id ) { try { - $cm = new CS_REST_General( $this->api_key() ); - $response = []; - - $lists = $this->get_lists(); - $segments = $this->get_segments(); - - $response['lists'] = ! empty( $lists ) ? $lists : []; - $response['segments'] = ! empty( $segments ) ? $segments : []; - - if ( $fetch_all ) { - $cm_send_mode = $this->retrieve_send_mode( $post_id ); - $cm_list_id = $this->retrieve_list_id( $post_id ); - $cm_segment_id = $this->retrieve_segment_id( $post_id ); - $cm_from_name = $this->retrieve_from_name( $post_id ); - $cm_from_email = $this->retrieve_from_email( $post_id ); - - $response['send_mode'] = $cm_send_mode; - $response['list_id'] = $cm_list_id; - $response['segment_id'] = $cm_segment_id; - $response['from_name'] = $cm_from_name; - $response['from_email'] = $cm_from_email; - $response['campaign'] = true; + if ( ! $this->has_api_credentials() ) { + throw new Exception( esc_html__( 'Missing or invalid Campaign Monitor credentials.', 'newspack-newsletters' ) ); + } + $send_list_id = get_post_meta( $post_id, 'send_list_id', true ); + $send_lists = $this->get_send_lists( // Get first 10 top-level send lists for autocomplete. + [ + 'ids' => $send_list_id ? [ $send_list_id ] : null, // If we have a selected list, make sure to fetch it. + 'type' => 'list', + ], + true + ); + if ( is_wp_error( $send_lists ) ) { + throw new Exception( wp_kses_post( $send_lists->get_error_message() ) ); + } + $newsletter_data = [ + 'campaign' => true, // Satisfy the JS API. + 'supports_multiple_test_recipients' => true, + 'lists' => $send_lists, + ]; + + // Handle legacy sender meta. + $from_name = get_post_meta( $post_id, 'senderName', true ); + $from_email = get_post_meta( $post_id, 'senderEmail', true ); + if ( ! $from_name ) { + $legacy_from_name = get_post_meta( $post_id, 'cm_from_name', true ); + if ( $legacy_from_name ) { + $newsletter_data['senderName'] = $legacy_from_name; + } + } + if ( ! $from_email ) { + $legacy_from_email = get_post_meta( $post_id, 'cm_from_email', true ); + if ( $legacy_from_email ) { + $newsletter_data['senderEmail'] = $legacy_from_email; + } } - return $response; + // Handle legacy send-to meta. + if ( ! $send_list_id ) { + $legacy_list_id = get_post_meta( $post_id, 'cm_list_id', true ) ?? get_post_meta( $post_id, 'cm_segment_id', true ); + if ( $legacy_list_id ) { + $newsletter_data['list_id'] = $legacy_list_id; + } + } + + return $newsletter_data; } catch ( Exception $e ) { return new WP_Error( 'newspack_newsletters_campaign_monitor_error', @@ -276,20 +452,20 @@ public function test( $post_id, $emails ) { } // Use the temporary test campaign ID to send a preview. - $preview = new CS_REST_Campaigns( $test_campaign->response, [ 'api_key' => $api_key ] ); - $preview->send_preview( $emails ); + $preview = new CS_REST_Campaigns( $test_campaign->response, [ 'api_key' => $api_key ] ); + $preview_response = $preview->send_preview( $emails ); // After sending a preview, delete the temporary test campaign. We must do this because the API doesn't support updating campaigns. - $delete = $preview->delete(); - - $data['result'] = $test_campaign->response; - $data['message'] = sprintf( - // translators: Message after successful test email. - __( 'Campaign Monitor test sent successfully to %s.', 'newspack-newsletters' ), - implode( ', ', $emails ) - ); - - return \rest_ensure_response( $data ); + $deleted = $preview->delete(); + $response = [ + 'result' => $preview_response->response, + 'message' => sprintf( + // translators: Message after successful test email. + __( 'Campaign Monitor test sent successfully to %s.', 'newspack-newsletters' ), + implode( ', ', $emails ) + ), + ]; + return \rest_ensure_response( $response ); } catch ( Exception $e ) { return new WP_Error( 'newspack_newsletters_campaign_monitor_error', @@ -305,7 +481,6 @@ public function test( $post_id, $emails ) { * @return object Args for sending a campaign or campaign preview. */ public function format_campaign_args( $post_id ) { - $data = $this->validate( $this->retrieve( $post_id, true ) ); $public_id = get_post_meta( $post_id, Newspack_Newsletters::PUBLIC_POST_ID_META, true ); // If we don't have a public ID, generate one and save it. @@ -317,18 +492,25 @@ public function format_campaign_args( $post_id ) { $args = [ 'Subject' => get_the_title( $post_id ), 'Name' => get_the_title( $post_id ) . ' ' . gmdate( 'h:i:s A' ), // Name must be unique. - 'FromName' => $data['from_name'], - 'FromEmail' => $data['from_email'], - 'ReplyTo' => $data['from_email'], + 'FromName' => get_post_meta( $post_id, 'senderName', true ), + 'FromEmail' => get_post_meta( $post_id, 'senderEmail', true ), + 'ReplyTo' => get_post_meta( $post_id, 'senderEmail', true ), 'HtmlUrl' => rest_url( $this::BASE_NAMESPACE . $this->service . '/' . $public_id . '/content' ), ]; - if ( 'list' === $data['send_mode'] ) { - $args['ListIDs'] = [ $data['list_id'] ]; - } else { - $args['SegmentIDs'] = [ $data['segment_id'] ]; + $send_list_id = get_post_meta( $post_id, 'send_list_id', true ); + if ( $send_list_id ) { + $send_list = $this->get_send_lists( [ 'ids' => $send_list_id ] ); + if ( ! empty( $send_list[0] ) ) { + $send_mode = $send_list[0]->get_entity_type(); + if ( 'list' === $send_mode ) { + $args['ListIDs'] = [ $send_list_id ]; + } elseif ( 'segment' === $send_mode ) { + $args['SegmentIDs'] = [ $send_list_id ]; + } + } } return $args; @@ -688,7 +870,11 @@ public static function get_labels( $context = '' ) { return array_merge( parent::get_labels(), [ - 'name' => 'Campaign Monitor', + 'name' => 'Campaign Monitor', + 'list' => __( 'list or segment', 'newspack-newsletters' ), // "list" in lower case singular format. + 'lists' => __( 'lists or segments', 'newspack-newsletters' ), // "list" in lower case plural format. + 'List' => __( 'List or Segment', 'newspack-newsletters' ), // "list" in uppercase case singular format. + 'Lists' => __( 'Lists or Segments', 'newspack-newsletters' ), // "list" in uppercase case plural format. ] ); } diff --git a/includes/service-providers/class-newspack-newsletters-service-provider-controller.php b/includes/service-providers/class-newspack-newsletters-service-provider-controller.php index 1effa8566..d8ac12fc1 100644 --- a/includes/service-providers/class-newspack-newsletters-service-provider-controller.php +++ b/includes/service-providers/class-newspack-newsletters-service-provider-controller.php @@ -17,7 +17,7 @@ abstract class Newspack_Newsletters_Service_Provider_Controller extends \WP_REST * * @var Newspack_Newsletters_Service_Provider $service_provider */ - private $service_provider; + protected $service_provider; /** * Newspack_Newsletters_Service_Provider_Controller constructor. @@ -32,7 +32,35 @@ public function __construct( $service_provider ) { * Endpoints common to all ESP Service Providers. */ public function register_routes() { - // Currently empty. Add endpoints common to all the ESP Service Providers. + \register_rest_route( + Newspack_Newsletters::API_NAMESPACE, + '(?P[\a-z]+)/sync-error', + [ + 'methods' => \WP_REST_Server::READABLE, + 'callback' => [ $this, 'api_get_sync_error' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], + 'args' => [ + 'id' => [ + 'sanitize_callback' => 'absint', + 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], + ], + ], + ] + ); + } + + /** + * Retrieve the sync error. + * + * @param WP_REST_Request $request API request object. + * @return WP_REST_Response|mixed API response or error. + */ + public function api_get_sync_error( $request ) { + $transient_name = $this->service_provider->get_transient_name( $request['id'] ); + $error_message = get_transient( $transient_name ); + // Delete the transient after reading it. + delete_transient( $transient_name ); + return self::get_api_response( [ 'message' => $error_message ] ); } /** diff --git a/includes/service-providers/class-newspack-newsletters-service-provider.php b/includes/service-providers/class-newspack-newsletters-service-provider.php index 39a157887..1db523a77 100644 --- a/includes/service-providers/class-newspack-newsletters-service-provider.php +++ b/includes/service-providers/class-newspack-newsletters-service-provider.php @@ -96,25 +96,6 @@ public static function instance() { return self::$instances[ static::class ]; } - /** - * Check capabilities for using the API for authoring tasks. - * - * @param WP_REST_Request $request API request object. - * @return bool|WP_Error - */ - public function api_authoring_permissions_check( $request ) { - if ( ! current_user_can( 'edit_others_posts' ) ) { - return new \WP_Error( - 'newspack_rest_forbidden', - esc_html__( 'You cannot use this resource.', 'newspack-newsletters' ), - [ - 'status' => 403, - ] - ); - } - return true; - } - /** * Handle newsletter post status changes. * @@ -537,8 +518,10 @@ public static function get_labels( $context = '' ) { 'name' => '', // The provider name. 'list' => __( 'list', 'newspack-newsletters' ), // "list" in lower case singular format. 'lists' => __( 'lists', 'newspack-newsletters' ), // "list" in lower case plural format. + 'sublist' => __( 'sublist', 'newspack-newsletters' ), // Sublist entities in lowercase singular format. 'List' => __( 'List', 'newspack-newsletters' ), // "list" in uppercase case singular format. 'Lists' => __( 'Lists', 'newspack-newsletters' ), // "list" in uppercase case plural format. + 'Sublist' => __( 'Sublist', 'newspack-newsletters' ), // Sublist entities in uppercase singular format. 'tag_prefix' => 'Newspack: ', // The prefix to be used in tags. 'tag_metabox_before_save' => __( 'Once this list is saved, a tag will be created for it.', 'newspack-newsletters' ), 'tag_metabox_after_save' => __( 'Tag created for this list', 'newspack-newsletters' ), @@ -651,12 +634,6 @@ public function update_contact_lists_handling_local( $email, $lists_to_add = [], if ( static::$support_local_lists ) { $lists_to_add = $this->update_contact_local_lists( $email, $lists_to_add, 'add' ); $lists_to_remove = $this->update_contact_local_lists( $email, $lists_to_remove, 'remove' ); - if ( is_wp_error( $lists_to_add ) ) { - return $lists_to_add; - } - if ( is_wp_error( $lists_to_remove ) ) { - return $lists_to_remove; - } } return $this->update_contact_lists( $email, $lists_to_add, $lists_to_remove ); } @@ -667,7 +644,7 @@ public function update_contact_lists_handling_local( $email, $lists_to_add = [], * @param string $email The contact email. * @param array $lists An array with List IDs, mixing local and providers lists. Only local lists will be handled. * @param string $action The action to be performed. add or remove. - * @return array|WP_Error The remaining lists that were not handled by this method, because they are not local lists. + * @return array The remaining lists that were not handled by this method, because they are not local lists. */ protected function update_contact_local_lists( $email, $lists = [], $action = 'add' ) { foreach ( $lists as $key => $list_id ) { @@ -676,7 +653,22 @@ protected function update_contact_local_lists( $email, $lists = [], $action = 'a $list = Subscription_List::from_public_id( $list_id ); if ( ! $list->is_configured_for_provider( $this->service ) ) { - return new WP_Error( 'List not properly configured for the provider' ); + do_action( + 'newspack_log', + 'newspack_esp_update_contact_lists_error', + __( 'Local list not properly configured for the provider', 'newspack-newsletters' ), + [ + 'type' => 'error', + 'data' => [ + 'provider' => $this->service, + 'list_id' => $list_id, + ], + 'user_email' => $email, + 'file' => 'newspack_' . $this->service, + ] + ); + unset( $lists[ $key ] ); + continue; } $list_settings = $list->get_provider_settings( $this->service ); @@ -685,11 +677,23 @@ protected function update_contact_local_lists( $email, $lists = [], $action = 'a } elseif ( 'remove' === $action ) { $this->remove_esp_local_list_from_contact( $email, $list_settings['tag_id'], $list_settings['list'] ); } - unset( $lists[ $key ] ); - } catch ( \InvalidArgumentException $e ) { - return new WP_Error( 'List not found' ); + do_action( + 'newspack_log', + 'newspack_esp_update_contact_lists_error', + __( 'Local list not found', 'newspack-newsletters' ), + [ + 'type' => 'error', + 'data' => [ + 'provider' => $this->service, + 'list_id' => $list_id, + ], + 'user_email' => $email, + 'file' => 'newspack_' . $this->service, + ] + ); + unset( $lists[ $key ] ); } } } diff --git a/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-controller.php b/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-controller.php index 6c6cd326a..02c76b877 100644 --- a/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-controller.php +++ b/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-controller.php @@ -54,7 +54,7 @@ public function register_routes() { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'verify_token' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], ] ); \register_rest_route( @@ -63,7 +63,7 @@ public function register_routes() { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'api_retrieve' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], 'args' => [ 'id' => [ 'sanitize_callback' => 'absint', @@ -78,7 +78,7 @@ public function register_routes() { [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'api_test' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], 'args' => [ 'id' => [ 'sanitize_callback' => 'absint', @@ -90,99 +90,6 @@ public function register_routes() { ], ] ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/sender', - [ - 'methods' => \WP_REST_Server::EDITABLE, - 'callback' => [ $this, 'api_sender' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], - 'args' => [ - 'id' => [ - 'sanitize_callback' => 'absint', - 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], - ], - 'from_name' => [ - 'sanitize_callback' => 'sanitize_text_field', - ], - 'reply_to' => [ - 'sanitize_callback' => 'sanitize_email', - ], - ], - ] - ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/list/(?P[\a-z]+)', - [ - 'methods' => \WP_REST_Server::EDITABLE, - 'callback' => [ $this, 'api_list' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], - 'args' => [ - 'id' => [ - 'sanitize_callback' => 'absint', - 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], - ], - 'list_id' => [ - 'sanitize_callback' => 'esc_attr', - ], - ], - ] - ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/list/(?P[\a-z]+)', - [ - 'methods' => \WP_REST_Server::DELETABLE, - 'callback' => [ $this, 'api_list' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], - 'args' => [ - 'id' => [ - 'sanitize_callback' => 'absint', - 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], - ], - 'list_id' => [ - 'sanitize_callback' => 'esc_attr', - ], - ], - ] - ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/segment/(?P[\a-z]+)', - [ - 'methods' => \WP_REST_Server::EDITABLE, - 'callback' => [ $this, 'api_segment' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], - 'args' => [ - 'id' => [ - 'sanitize_callback' => 'absint', - 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], - ], - 'segment_id' => [ - 'sanitize_callback' => 'esc_attr', - ], - ], - ] - ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/segment/', - [ - 'methods' => \WP_REST_Server::DELETABLE, - 'callback' => [ $this, 'api_segment' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], - 'args' => [ - 'id' => [ - 'sanitize_callback' => 'absint', - 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], - ], - 'segment_id' => [ - 'sanitize_callback' => 'esc_attr', - ], - ], - ] - ); } /** @@ -224,60 +131,4 @@ public function api_test( $request ) { ); return self::get_api_response( $response ); } - - /** - * Set the sender name and email for the campaign. - * - * @param WP_REST_Request $request API request object. - * @return WP_REST_Response|mixed API response or error. - */ - public function api_sender( $request ) { - $response = $this->service_provider->sender( - $request['id'], - $request['from_name'], - $request['reply_to'] - ); - return self::get_api_response( $response ); - } - - /** - * Set list for a campaign. - * - * @param WP_REST_Request $request API request object. - * @return WP_REST_Response|mixed API response or error. - */ - public function api_list( $request ) { - if ( 'DELETE' === $request->get_method() ) { - $response = $this->service_provider->unset_list( - $request['id'], - $request['list_id'] - ); - } else { - $response = $this->service_provider->list( - $request['id'], - $request['list_id'] - ); - } - return self::get_api_response( $response ); - } - - /** - * Set segment for a campaign. - * - * @param WP_REST_Request $request API request object. - * @return WP_REST_Response|mixed API response or error. - */ - public function api_segment( $request ) { - if ( 'DELETE' === $request->get_method() ) { - $response = $this->service_provider->unset_segment( - $request['id'] - ); - } else { - $response = $this->service_provider->set_segment( - $request['id'], - $request['segment_id'] - ); - } - return self::get_api_response( $response ); - } } diff --git a/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-sdk.php b/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-sdk.php index 821ee8036..2e3206389 100644 --- a/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-sdk.php +++ b/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact-sdk.php @@ -297,10 +297,12 @@ public function get_account_info() { /** * Get account email addresses * + * @param array $args Array of query args. + * * @return object Email addresses. */ - public function get_email_addresses() { - return $this->request( 'GET', 'account/emails' ); + public function get_email_addresses( $args = [] ) { + return $this->request( 'GET', 'account/emails', [ 'query' => $args ] ); } /** @@ -309,26 +311,68 @@ public function get_email_addresses() { * @return object Contact lists. */ public function get_contact_lists() { + $args = [ + 'include_count' => 'true', + 'include_membership_count' => 'active', + 'limit' => 1000, + 'status' => 'active', + ]; return $this->request( 'GET', 'contact_lists', - [ 'query' => [ 'include_count' => 'true' ] ] + [ 'query' => $args ] )->lists; } + /** + * Get a Contact List by ID + * + * @param string $id Contact List ID. + * + * @return object Contact list. + */ + public function get_contact_list( $id ) { + $args = [ + 'include_membership_count' => 'active', + ]; + return $this->request( + 'GET', + 'contact_lists/' . $id, + [ 'query' => $args ] + ); + } + /** * Get segments * * @return array */ public function get_segments() { + $args = [ + 'limit' => 1000, + 'sort_by' => 'date', + ]; return $this->request( 'GET', 'segments', - [ 'query' => [ 'sort_by' => 'name' ] ] + [ 'query' => $args ] )->segments; } + /** + * Get a segment by ID + * + * @param string $id Segment ID. + * + * @return object Segment. + */ + public function get_segment( $id ) { + return $this->request( + 'GET', + 'segments/' . $id + ); + } + /** * Get v3 campaign UUID if matches v2 format. * diff --git a/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact.php b/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact.php index 66aba7cbc..9498da041 100644 --- a/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact.php +++ b/includes/service-providers/constant_contact/class-newspack-newsletters-constant-contact.php @@ -7,11 +7,28 @@ defined( 'ABSPATH' ) || exit; +use Newspack\Newsletters\Send_Lists; +use Newspack\Newsletters\Send_List; + /** * Main Newspack Newsletters Class for Constant Contact ESP. */ final class Newspack_Newsletters_Constant_Contact extends \Newspack_Newsletters_Service_Provider { + /** + * Provider name. + * + * @var string + */ + public $name = 'Contant Constact'; + + /** + * Cached instance of the CC SDK. + * + * @var Newspack_Newsletters_Constant_Contact_SDK + */ + private $cc = null; + /** * Cached lists. * @@ -20,18 +37,18 @@ final class Newspack_Newsletters_Constant_Contact extends \Newspack_Newsletters_ private $lists = null; /** - * Cached contact data. + * Cached segments. * * @var array */ - private $contact_data = []; + private $segments = null; /** - * Provider name. + * Cached contact data. * - * @var string + * @var array */ - public $name = 'Contant Constact'; + private $contact_data = []; /** * Whether the provider has support to tags and tags based Subscription Lists. @@ -46,6 +63,7 @@ final class Newspack_Newsletters_Constant_Contact extends \Newspack_Newsletters_ public function __construct() { $this->service = 'constant_contact'; $this->controller = new Newspack_Newsletters_Constant_Contact_Controller( $this ); + $credentials = $this->api_credentials(); add_action( 'admin_init', [ $this, 'oauth_callback' ] ); add_action( 'update_option_newspack_newsletters_constant_contact_api_key', [ $this, 'clear_tokens' ], 10, 2 ); @@ -79,6 +97,22 @@ public function has_api_credentials() { return ! empty( $this->api_key() ) && ! empty( $this->api_secret() ); } + /** + * Get or create a cached instance of the Constant Contact SDK. + */ + public function get_sdk() { + if ( $this->cc ) { + return $this->cc; + } + $credentials = $this->api_credentials(); + $this->cc = new Newspack_Newsletters_Constant_Contact_SDK( + $credentials['api_key'], + $credentials['api_secret'], + $credentials['access_token'] + ); + return $this->cc; + } + /** * Verify service provider connection. * @@ -88,15 +122,9 @@ public function has_api_credentials() { */ public function verify_token( $refresh = true ) { try { - $credentials = $this->api_credentials(); $redirect_uri = $this->get_oauth_redirect_uri(); - $cc = new Newspack_Newsletters_Constant_Contact_SDK( - $credentials['api_key'], - $credentials['api_secret'], - $credentials['access_token'] - ); - - $response = [ + $cc = $this->get_sdk(); + $response = [ 'error' => null, 'valid' => false, 'auth_url' => $cc->get_auth_code_url( wp_create_nonce( 'constant_contact_oauth2' ), $redirect_uri ), @@ -106,7 +134,9 @@ public function verify_token( $refresh = true ) { $response['valid'] = true; return $response; } + // If we have a refresh token, we can get a new access token. + $credentials = $this->api_credentials(); if ( $refresh && ! empty( $credentials['refresh_token'] ) ) { $token = $cc->refresh_token( $credentials['refresh_token'] ); $response['valid'] = $this->set_access_token( $token->access_token, $token->refresh_token ); @@ -189,7 +219,7 @@ public function oauth_callback() { * @return boolean Whether we are connected. */ private function connect( $redirect_uri, $code ) { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret() ); + $cc = $this->get_sdk(); $token = $cc->get_access_token( $redirect_uri, $code ); if ( ! $token || ! isset( $token->access_token ) ) { return false; @@ -306,7 +336,7 @@ public function list( $post_id, $list_id ) { } try { $cc_campaign_id = $this->retrieve_campaign_id( $post_id ); - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $campaign = $cc->get_campaign( $cc_campaign_id ); $activity = $campaign->activity; @@ -344,7 +374,7 @@ public function unset_list( $post_id, $list_id ) { } try { $cc_campaign_id = $this->retrieve_campaign_id( $post_id ); - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $campaign = $cc->get_campaign( $cc_campaign_id ); $activity = $campaign->activity; @@ -381,13 +411,13 @@ public function set_segment( $post_id, $segment_id ) { } try { $cc_campaign_id = $this->retrieve_campaign_id( $post_id ); - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $campaign = $cc->get_campaign( $cc_campaign_id ); $activity = $campaign->activity; if ( ! in_array( $segment_id, $activity->segment_ids, true ) ) { - $activity->segment_ids[] = $segment_id; + $activity->segment_ids = [ $segment_id ]; $activity->contact_list_ids = []; } @@ -418,7 +448,7 @@ public function unset_segment( $post_id ) { } try { $cc_campaign_id = $this->retrieve_campaign_id( $post_id ); - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $campaign = $cc->get_campaign( $cc_campaign_id ); $activity = $campaign->activity; @@ -437,37 +467,159 @@ public function unset_segment( $post_id ) { } } + /** + * Wrapper for fetching campaign from CC API. + * + * @param string $cc_campaign_id Campaign ID. + * @return object|WP_Error API Response or error. + */ + private function fetch_synced_campaign( $cc_campaign_id ) { + try { + $cc = $this->get_sdk(); + $campaign = $cc->get_campaign( $cc_campaign_id ); + return $campaign; + } catch ( Exception $e ) { + return new WP_Error( + 'newspack_newsletters_constant_contact_error', + $e->getMessage() + ); + } + } + + /** + * Get the campaign link. + * + * @param object $campaign Campaign object. + * + * @return string Campaign link. + */ + private static function get_campaign_link( $campaign ) { + if ( empty( $campaign->campaign_activities ) ) { + return ''; + } + $activity_index = array_search( 'primary_email', array_column( $campaign->campaign_activities, 'role' ) ); + if ( false === $activity_index ) { + return ''; + } + $activity = $campaign->campaign_activities[ $activity_index ]; + return sprintf( 'https://app.constantcontact.com/pages/ace/v1#/%s', $activity->campaign_activity_id ); + } + + /** + * Given a campaign object from the ESP or legacy newsletterData, extract sender and send-to info. + * + * @param array $newsletter_data Newsletter data from the ESP. + * @return array { + * Extracted sender and send-to info. All keys are optional and will be + * returned only if found in the campaign data. + * + * @type string $senderName Sender name. + * @type string $senderEmail Sender email. + * @type string $list_id List ID. + * @type string $sublist_id Sublist ID. + * } + */ + public function extract_campaign_info( $newsletter_data ) { + $campaign_info = []; + if ( empty( $newsletter_data['campaign'] ) ) { + return $campaign_info; + } + + // Convert stdClass object to an array. + $campaign = json_decode( wp_json_encode( $newsletter_data['campaign'] ), true ); + + // Sender info. + if ( ! empty( $campaign['activity']['from_name'] ) ) { + $campaign_info['senderName'] = $campaign['activity']['from_name']; + } + if ( ! empty( $campaign['activity']['from_email'] ) ) { + $campaign_info['senderEmail'] = $campaign['activity']['from_email']; + } + + // List. + if ( ! empty( $campaign['activity']['contact_list_ids'][0] ) ) { + $campaign_info['list_id'] = $campaign['activity']['contact_list_ids'][0]; + } + + // Segment. CC campaigns can be sent to either lists or segments, so if a segment is set it'll override the list. + if ( ! empty( $campaign['activity']['segment_ids'][0] ) ) { + $campaign_info['list_id'] = $campaign['activity']['segment_ids'][0]; + } + + return $campaign_info; + } + /** * Retrieve a campaign. * * @param integer $post_id Numeric ID of the Newsletter post. * @return object|WP_Error API Response or error. + * @throws Exception Error message. */ public function retrieve( $post_id ) { - if ( ! $this->has_api_credentials() || ! $this->has_valid_connection() ) { - return []; - } try { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); - $cc_campaign_id = get_post_meta( $post_id, 'cc_campaign_id', true ); + if ( ! $this->has_api_credentials() ) { + throw new Exception( esc_html__( 'Missing or invalid Constant Contact credentials.', 'newspack-newsletters' ) ); + } + if ( ! $this->has_valid_connection() ) { + throw new Exception( esc_html__( 'Unable to connect to Constant Contact API. Please try authorizing your connection or obtaining new credentials.', 'newspack-newsletters' ) ); + } + $cc_campaign_id = get_post_meta( $post_id, 'cc_campaign_id', true ); if ( ! $cc_campaign_id ) { - $campaign = $this->sync( get_post( $post_id ) ); + $campaign = $this->sync( get_post( $post_id ) ); + if ( is_wp_error( $campaign ) ) { + throw new Exception( wp_kses_post( $campaign->get_error_message() ) ); + } $cc_campaign_id = $campaign->campaign_id; } else { - $campaign = $cc->get_campaign( $cc_campaign_id ); - } + Newspack_Newsletters_Logger::log( 'Retrieving campaign ' . $cc_campaign_id . ' for post ID ' . $post_id ); + $campaign = $this->fetch_synced_campaign( $cc_campaign_id ); - $lists = $cc->get_contact_lists(); - $segments = $cc->get_segments(); + // If we couldn't get the campaign, delete the cc_campaign_id so it gets recreated on the next sync. + if ( is_wp_error( $campaign ) ) { + delete_post_meta( $post_id, 'cc_campaign_id' ); + throw new Exception( wp_kses_post( $campaign->get_error_message() ) ); + } + } - return [ - 'lists' => $lists, - 'campaign' => $campaign, - 'campaign_id' => $cc_campaign_id, - 'segments' => $segments, + $campaign_info = $this->extract_campaign_info( [ 'campaign' => $campaign ] ); + $list_id = $campaign_info['list_id'] ?? null; + $send_list_id = get_post_meta( $post_id, 'send_list_id', true ); + $newsletter_data = [ + 'campaign' => $campaign, + 'campaign_id' => $cc_campaign_id, + 'link' => $this->get_campaign_link( $campaign ), + 'allowed_sender_emails' => $this->get_verified_email_addresses(), // Get allowed email addresses for sender UI. + 'email_settings_url' => 'https://app.constantcontact.com/pages/myaccount/settings/emails', ]; + // Reconcile campaign settings with info fetched from the ESP for a true two-way sync. + if ( ! empty( $campaign_info['senderName'] ) && $campaign_info['senderName'] !== get_post_meta( $post_id, 'senderName', true ) ) { + $newsletter_data['senderName'] = $campaign_info['senderName']; // If campaign has different sender info set, update ours. + } + if ( ! empty( $campaign_info['senderEmail'] ) && $campaign_info['senderEmail'] !== get_post_meta( $post_id, 'senderEmail', true ) ) { + $newsletter_data['senderEmail'] = $campaign_info['senderEmail']; // If campaign has different sender info set, update ours. + } + if ( $list_id && $list_id !== $send_list_id ) { + $newsletter_data['send_list_id'] = strval( $list_id ); // If campaign has different list or segment set, update ours. + $send_list_id = $newsletter_data['send_list_id']; + } + + // Prefetch send list info if we have a selected list and/or sublist. + $send_lists = $this->get_send_lists( + [ + 'ids' => $send_list_id ? [ $send_list_id ] : null, // If we have a selected list, make sure to fetch it. + 'type' => 'list', + ], + true + ); + if ( is_wp_error( $send_lists ) ) { + throw new Exception( wp_kses_post( $send_lists->get_error_message() ) ); + } + $newsletter_data['lists'] = $send_lists; + + return $newsletter_data; } catch ( Exception $e ) { return new WP_Error( 'newspack_newsletters_constant_contact_error', @@ -494,7 +646,7 @@ public function sender( $post_id, $from_name, $reply_to ) { try { $post = get_post( $post_id ); $cc_campaign_id = $this->retrieve_campaign_id( $post_id ); - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $renderer = new Newspack_Newsletters_Renderer(); $content = $renderer->retrieve_email_html( $post ); @@ -536,8 +688,7 @@ public function test( $post_id, $emails ) { ); } try { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); - + $cc = $this->get_sdk(); $data = $this->retrieve( $post_id ); if ( is_wp_error( $data ) ) { @@ -563,6 +714,90 @@ public function test( $post_id, $emails ) { } } + /** + * Get all of the verified email addresses associated with the CC account. + * See: https://developer.constantcontact.com/api_reference/index.html#!/Account_Services/retrieveEmailAddresses. + */ + public function get_verified_email_addresses() { + $cc = $this->get_sdk(); + $email_addresses = (array) $cc->get_email_addresses( [ 'confirm_status' => 'CONFIRMED' ] ); + + return array_map( + function( $email ) { + return $email->email_address; + }, + $email_addresses + ); + } + + /** + * Get a payload for syncing post data to the ESP campaign. + * + * @param WP_Post|int $post Post object or ID. + * + * @return array|WP_Error Payload for syncing or error. + */ + public function get_sync_payload( $post ) { + $cc = $this->get_sdk(); + $renderer = new Newspack_Newsletters_Renderer(); + $content = $renderer->retrieve_email_html( $post ); + $auto_draft_html = '[[trackingImage]]

Auto draft

'; + $account_info = $cc->get_account_info(); + $sender_name = get_post_meta( $post->ID, 'senderName', true ); + $sender_email = get_post_meta( $post->ID, 'senderEmail', true ); + + // If we don't have a sender name or email, set default values. + if ( ! $sender_name && $account_info->organization_name ) { + $sender_name = $account_info->organization_name; + } elseif ( ! $sender_name && $account_info->first_name && $account_info->last_name ) { + $sender_name = $account_info->first_name . ' ' . $account_info->last_name; + } + + $verified_email_addresses = $this->get_verified_email_addresses(); + if ( empty( $verified_email_addresses ) ) { + return new WP_Error( + 'newspack_newsletters_constant_contact_error', + __( 'There are no verified email addresses in the Constant Contact account.', 'newspack-newsletters' ) + ); + } + if ( ! $sender_email ) { + $sender_email = $verified_email_addresses[0]; + } + if ( ! in_array( $sender_email, $verified_email_addresses, true ) ) { + return new WP_Error( + 'newspack_newsletters_constant_contact_error', + __( 'Sender email must be a verified Constant Contact account email address.', 'newspack-newsletters' ) + ); + } + $payload = [ + 'format_type' => 5, // https://v3.developer.constantcontact.com/api_guide/email_campaigns_overview.html#collapse-format-types . + 'html_content' => empty( $content ) ? $auto_draft_html : $content, + 'subject' => $post->post_title, + 'from_name' => $sender_name ?? __( 'Sender Name', 'newspack-newsletters' ), + 'from_email' => $sender_email, + 'reply_to_email' => $sender_email, + ]; + if ( $account_info->physical_address ) { + $payload['physical_address_in_footer'] = $account_info->physical_address; + } + + // Sync send-to selections. + $send_lists = $this->get_send_lists( [ 'ids' => get_post_meta( $post->ID, 'send_list_id', true ) ] ); + if ( is_wp_error( $send_lists ) ) { + return $send_lists; + } + if ( ! empty( $send_lists[0] ) ) { + $send_list = $send_lists[0]; + if ( 'list' === $send_list->get_entity_type() ) { + $payload['contact_list_ids'] = [ $send_list->get_id() ]; + } elseif ( 'segment' === $send_list->get_entity_type() ) { + $payload['segment_ids'] = [ $send_list->get_id() ]; + } + } + + return $payload; + } + /** * Synchronize post with corresponding ESP campaign. * @@ -594,21 +829,28 @@ public function sync( $post ) { return; } - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $cc_campaign_id = get_post_meta( $post->ID, 'cc_campaign_id', true ); - $renderer = new Newspack_Newsletters_Renderer(); - $content = $renderer->retrieve_email_html( $post ); - $auto_draft_html = '[[trackingImage]]

Auto draft

'; - $account_info = $cc->get_account_info(); - - $activity_data = [ - 'format_type' => 5, // https://v3.developer.constantcontact.com/api_guide/email_campaigns_overview.html#collapse-format-types . - 'html_content' => empty( $content ) ? $auto_draft_html : $content, - 'subject' => $post->post_title, - ]; + $payload = $this->get_sync_payload( $post ); + + if ( is_wp_error( $payload ) ) { + throw new Exception( esc_html( $payload->get_error_message() ) ); + } - if ( $account_info->physical_address ) { - $activity_data['physical_address_in_footer'] = $account_info->physical_address; + /** + * Filter the metadata payload sent to CC when syncing. + * + * Allows custom tracking codes to be sent. + * + * @param array $payload CC payload. + * @param object $post Post object. + * @param string $cc_campaign_id CC campaign ID, if defined. + */ + $payload = apply_filters( 'newspack_newsletters_cc_payload_sync', $payload, $post, $cc_campaign_id ); + + // If we have any errors in the payload, throw an exception. + if ( is_wp_error( $payload ) ) { + throw new Exception( esc_html( $payload->get_error_message() ) ); } if ( $cc_campaign_id ) { @@ -616,73 +858,31 @@ public function sync( $post ) { // Constant Constact only allow updates on DRAFT or SENT status. if ( ! in_array( $campaign->current_status, [ 'DRAFT', 'SENT' ], true ) ) { - return; + throw new Exception( + __( 'The newsletter campaign must have a DRAFT or SENT status.', 'newspack-newsletters' ) + ); } - $activity = array_merge( - $activity_data, - [ - 'contact_list_ids' => $campaign->activity->contact_list_ids, - 'from_name' => $campaign->activity->from_name, - 'from_email' => $campaign->activity->from_email, - 'reply_to_email' => $campaign->activity->reply_to_email, - ] - ); + $cc->update_campaign_activity( $campaign->activity->campaign_activity_id, $payload ); - $activity_result = $cc->update_campaign_activity( $campaign->activity->campaign_activity_id, $activity ); - $name_result = $cc->update_campaign_name( $cc_campaign_id, $this->get_campaign_name( $post ) ); + // Update campaign name. + $campaign_name = $this->get_campaign_name( $post ); + if ( $campaign->name !== $campaign_name ) { + $cc->update_campaign_name( $cc_campaign_id, $campaign_name ); + } $campaign_result = $cc->get_campaign( $cc_campaign_id ); } else { - - $initial_sender = __( 'Sender Name', 'newspack-newsletters' ); - if ( $account_info->organization_name ) { - $initial_sender = $account_info->organization_name; - } elseif ( $account_info->first_name && $account_info->last_name ) { - $initial_sender = $account_info->first_name . ' ' . $account_info->last_name; - } - - $email_addresses = (array) $cc->get_email_addresses(); - $verified_email_addresses = array_values( - array_filter( - $email_addresses, - function ( $email ) { - return 'CONFIRMED' === $email->confirm_status; - } - ) - ); - - if ( empty( $verified_email_addresses ) ) { - throw new Exception( __( 'There are no verified email addresses in the Constant Contact account.', 'newspack-newsletters' ) ); - } - - $initial_email_address = $verified_email_addresses[0]->email_address; - $campaign = [ 'name' => $this->get_campaign_name( $post ), - 'email_campaign_activities' => [ - array_merge( - $activity_data, - [ - 'subject' => $post->post_title, - 'from_name' => $initial_sender, - 'from_email' => $initial_email_address, - 'reply_to_email' => $initial_email_address, - ] - ), - ], + 'email_campaign_activities' => [ $payload ], ]; $campaign_result = $cc->create_campaign( $campaign ); } update_post_meta( $post->ID, 'cc_campaign_id', $campaign_result->campaign_id ); - // Retrieve and store campaign data. - $data = $this->retrieve( $post->ID ); - if ( ! is_wp_error( $data ) ) { - update_post_meta( $post->ID, 'newsletterData', $data ); - } - return $campaign_result; + return $campaign_result; } catch ( Exception $e ) { set_transient( $transient_name, __( 'Error syncing with ESP. ', 'newspack-newsletters' ) . $e->getMessage(), 45 ); return new WP_Error( 'newspack_newsletters_constant_contact_error', $e->getMessage() ); @@ -745,7 +945,7 @@ public function send( $post ) { } try { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $cc->create_schedule( $sync_result->activity->campaign_activity_id ); } catch ( Exception $e ) { return new WP_Error( @@ -781,7 +981,7 @@ public function trash( $post_id ) { return; } - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $campaign = $cc->get_campaign( $cc_campaign_id ); if ( $campaign && 'DRAFT' === $campaign->current_status ) { @@ -818,23 +1018,154 @@ public function get_lists() { return $this->lists; } try { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); - $this->lists = array_map( - function ( $list ) { - return [ - 'id' => $list->list_id, - 'name' => $list->name, - 'membership_count' => $list->membership_count, - ]; - }, - $cc->get_contact_lists() - ); + $cc = $this->get_sdk(); + if ( ! $this->lists ) { + $this->lists = array_map( + function ( $list ) { + return [ + 'id' => $list->list_id, + 'name' => $list->name, + 'membership_count' => $list->membership_count, + ]; + }, + $cc->get_contact_lists() + ); + } + return $this->lists; } catch ( Exception $e ) { return new WP_Error( 'newspack_newsletters_error', $e->getMessage() ); } } + /** + * Get segments. + * + * @return array|WP_Error List of existing segments or error. + */ + public function get_segments() { + if ( null !== $this->segments ) { + return $this->segments; + } + try { + $cc = $this->get_sdk(); + if ( ! $this->segments ) { + $this->segments = array_map( + function ( $segment ) { + return [ + 'id' => $segment->segment_id, + 'name' => $segment->name, + ]; + }, + $cc->get_segments() + ); + } + + return $this->segments; + } catch ( Exception $e ) { + return new WP_Error( 'newspack_newsletters_error', $e->getMessage() ); + } + } + + /** + * Get all applicable lists and segments as Send_List objects. + * Note that in CC, campaigns can be sent to either lists or segments, not both, + * so both entity types should be treated as top-level send lists. + * + * @param array $args Array of search args. See Send_Lists::get_default_args() for supported params and default values. + * @param boolean $to_array If true, convert Send_List objects to arrays before returning. + * + * @return Send_List[]|array|WP_Error Array of Send_List objects or arrays on success, or WP_Error object on failure. + */ + public function get_send_lists( $args = [], $to_array = false ) { + $lists = $this->get_lists(); + if ( is_wp_error( $lists ) ) { + return $lists; + } + $send_lists = array_map( + function( $list ) { + $config = [ + 'provider' => $this->service, + 'type' => 'list', + 'id' => $list['id'], + 'name' => $list['name'], + 'entity_type' => 'list', + 'count' => $list['membership_count'], + 'edit_link' => 'https://app.constantcontact.com/pages/contacts/ui#contacts/' . $list['id'], + ]; + + return new Send_List( $config ); + }, + $lists + ); + $segments = $this->get_segments(); + if ( is_wp_error( $segments ) ) { + return $segments; + } + $send_segments = array_map( + function( $segment ) { + $segment_id = (string) $segment['id']; + $config = [ + 'provider' => $this->service, + 'type' => 'list', // In CC, segments and lists have the same hierarchy. + 'id' => $segment_id, + 'name' => $segment['name'], + 'entity_type' => 'segment', + 'edit_link' => "https://app.constantcontact.com/pages/contacts/ui#segments/$segment_id/preview", + ]; + + return new Send_List( $config ); + }, + $segments + ); + $send_lists = array_merge( $send_lists, $send_segments ); + $filtered_lists = $send_lists; + if ( ! empty( $args['ids'] ) ) { + $ids = ! is_array( $args['ids'] ) ? [ $args['ids'] ] : $args['ids']; + $filtered_lists = array_values( + array_filter( + $send_lists, + function ( $list ) use ( $ids ) { + return Send_Lists::matches_id( $ids, $list->get_id() ); + } + ) + ); + } + if ( ! empty( $args['search'] ) ) { + $search = ! is_array( $args['search'] ) ? [ $args['search'] ] : $args['search']; + $filtered_lists = array_values( + array_filter( + $send_lists, + function ( $list ) use ( $search ) { + return Send_Lists::matches_search( + $search, + [ + $list->get_id(), + $list->get_name(), + $list->get_entity_type(), + ] + ); + } + ) + ); + } + + if ( ! empty( $args['limit'] ) ) { + $filtered_lists = array_slice( $filtered_lists, 0, $args['limit'] ); + } + + // Convert to arrays if requested. + if ( $to_array ) { + $filtered_lists = array_map( + function ( $list ) { + return $list->to_array(); + }, + $filtered_lists + ); + } + return $filtered_lists; + } + /** * Add contact to a list or update an existing contact. * @@ -850,7 +1181,7 @@ function ( $list ) { * @return array|WP_Error Contact data if the contact was added or error if failed. */ public function add_contact( $contact, $list_id = false ) { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $data = []; if ( $list_id ) { $data['list_ids'] = [ $list_id ]; @@ -892,7 +1223,7 @@ public function add_contact( $contact, $list_id = false ) { * @return array|WP_Error Response or error if contact was not found. */ public function get_contact_data( $email, $return_details = false ) { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $contact = $cc->get_contact( $email ); if ( ! $contact || is_wp_error( $contact ) ) { return new WP_Error( @@ -936,7 +1267,7 @@ public function get_contact_lists( $email ) { * @return true|WP_Error True if the contact was updated or error. */ public function update_contact_lists( $email, $lists_to_add = [], $lists_to_remove = [] ) { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $contact_data = $this->get_contact_data( $email ); if ( is_wp_error( $contact_data ) ) { /** Create contact */ @@ -983,6 +1314,10 @@ public static function get_labels( $context = '' ) { 'name' => 'Constant Contact', 'list_explanation' => __( 'Constant Contact List', 'newspack-newsletters' ), 'local_list_explanation' => __( 'Constant Contact Tag', 'newspack-newsletters' ), + 'list' => __( 'list or segment', 'newspack-newsletters' ), // "list" in lower case singular format. + 'lists' => __( 'lists or segments', 'newspack-newsletters' ), // "list" in lower case plural format. + 'List' => __( 'List or Segment', 'newspack-newsletters' ), // "list" in uppercase case singular format. + 'Lists' => __( 'Lists or Segments', 'newspack-newsletters' ), // "list" in uppercase case plural format. ] ); } @@ -996,7 +1331,7 @@ public static function get_labels( $context = '' ) { * @return int|WP_Error The tag ID on success. WP_Error on failure. */ public function get_tag_id( $tag_name, $create_if_not_found = true, $list_id = null ) { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $tag = $cc->get_tag_by_name( $tag_name ); if ( is_wp_error( $tag ) && $create_if_not_found ) { $tag = $this->create_tag( $tag_name ); @@ -1015,7 +1350,7 @@ public function get_tag_id( $tag_name, $create_if_not_found = true, $list_id = n * @return string|WP_Error The tag name on success. WP_Error on failure. */ public function get_tag_by_id( $tag_id, $list_id = null ) { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $tag = $cc->get_tag_by_id( $tag_id ); if ( is_wp_error( $tag ) ) { return $tag; @@ -1031,7 +1366,7 @@ public function get_tag_by_id( $tag_id, $list_id = null ) { * @return array|WP_Error The tag representation with at least 'id' and 'name' keys on succes. WP_Error on failure. */ public function create_tag( $tag, $list_id = null ) { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $tag = $cc->create_tag( $tag ); if ( is_wp_error( $tag ) ) { return $tag; @@ -1053,7 +1388,7 @@ public function create_tag( $tag, $list_id = null ) { * @return array|WP_Error The tag representation with at least 'id' and 'name' keys on succes. WP_Error on failure. */ public function update_tag( $tag_id, $tag, $list_id = null ) { - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); $tag = $cc->update_tag( $tag_id, $tag ); if ( is_wp_error( $tag ) ) { return $tag; @@ -1081,7 +1416,7 @@ public function add_tag_to_contact( $email, $tag, $list_id = null ) { return true; } $new_tags = array_merge( $tags, [ $tag ] ); - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); return $cc->upsert_contact( $email, [ 'taggings' => $new_tags ] ); } @@ -1099,7 +1434,7 @@ public function remove_tag_from_contact( $email, $tag, $list_id = null ) { if ( count( $new_tags ) === count( $tags ) ) { return true; } - $cc = new Newspack_Newsletters_Constant_Contact_SDK( $this->api_key(), $this->api_secret(), $this->access_token() ); + $cc = $this->get_sdk(); return $cc->upsert_contact( $email, [ 'taggings' => $new_tags ] ); } diff --git a/includes/service-providers/interface-newspack-newsletters-esp-service.php b/includes/service-providers/interface-newspack-newsletters-esp-service.php index bec6ad564..7ca0bc4bf 100644 --- a/includes/service-providers/interface-newspack-newsletters-esp-service.php +++ b/includes/service-providers/interface-newspack-newsletters-esp-service.php @@ -50,17 +50,6 @@ public function list( $post_id, $list_id ); */ public function retrieve( $post_id ); - /** - * Set sender data. - * - * @param string $post_id Numeric ID of the campaign. - * @param string $from_name Sender name. - * @param string $reply_to Reply to email address. - * - * @return array|WP_Error API Response or error. - */ - public function sender( $post_id, $from_name, $reply_to ); - /** * Send test email or emails. * @@ -87,6 +76,16 @@ public function sync( $post ); */ public function get_lists(); + /** + * Get the ESP's available lists and sublists, reformatted as Send_List items or an array of config data. + * + * @param array $args Array of search args. See Send_Lists::get_default_args() for supported params and default values. + * @param boolean $to_array If true, convert Send_List objects to arrays before returning. + * + * @return Send_List[]|array|WP_Error Array of Send_List objects or arrays on success, or WP_Error object on failure. + */ + public function get_send_lists( $args, $to_array = false ); + /** * Add contact to a list. * diff --git a/includes/service-providers/letterhead/class-newspack-newsletters-letterhead.php b/includes/service-providers/letterhead/class-newspack-newsletters-letterhead.php index 3c2a7e25d..a731f0880 100644 --- a/includes/service-providers/letterhead/class-newspack-newsletters-letterhead.php +++ b/includes/service-providers/letterhead/class-newspack-newsletters-letterhead.php @@ -70,6 +70,16 @@ public function get_lists() { // TODO: Implement get_lists() method. } + /** + * Get send lists + * + * @param array $args Array of search args. See Send_Lists::get_default_args() for supported params and default values. + * @param boolean $to_array If true, convert Send_List objects to arrays before returning. + * + * @return void + */ + public function get_send_lists( $args = [], $to_array = false ) {} // Not used. + /** * This will call the Letterhead promotions API with the specific date passed as the * argument and the appropriate credentials. It will return an array. diff --git a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-cached-data.php b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-cached-data.php index 811c200d2..ef6b4d768 100644 --- a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-cached-data.php +++ b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-cached-data.php @@ -130,6 +130,35 @@ private static function get_mc_api() { } } + /** + * Get audiences (lists). + * + * @param int|null $limit (Optional) The maximum number of items to return. If not given, will get all items. + * + * @throws Exception In case of errors while fetching data from the server. + * @return array|WP_Error The audiences, or WP_Error if there was an error. + */ + public static function get_lists( $limit = null ) { + // If we've already gotten or fetched lists in this request, return those. + if ( ! empty( self::$memoized_data['lists'] ) ) { + return self::$memoized_data['lists']; + } + + $data = get_option( self::get_lists_cache_key() ); + if ( ! $data || self::is_cache_expired() ) { + Newspack_Newsletters_Logger::log( 'Mailchimp cache: No data found. Fetching lists from ESP.' ); + $data = self::fetch_lists( $limit ); + } else { + Newspack_Newsletters_Logger::log( 'Mailchimp cache: serving from cache' ); + } + + self::$memoized_data['lists'] = $data; + if ( $limit ) { + $data = array_slice( $data, 0, $limit ); + } + return $data; + } + /** * Get segments of a given audience (list) * @@ -200,6 +229,13 @@ private static function get_mc_instance() { return Newspack_Newsletters_Mailchimp::instance(); } + /** + * Get the cache key for the cached lists data. + */ + private static function get_lists_cache_key() { + return self::OPTION_PREFIX . '_lists'; + } + /** * Get the cache key for a given list * @@ -211,12 +247,12 @@ private static function get_cache_key( $list_id ) { } /** - * Get the cache date key for a given list + * Get the cache date key for a given list or all lists * - * @param string $list_id The List ID. + * @param string $list_id The List ID, or 'lists' for the cached lists data. * @return string The cache key */ - private static function get_cache_date_key( $list_id ) { + private static function get_cache_date_key( $list_id = 'lists' ) { return self::OPTION_PREFIX . '_date_' . $list_id; } @@ -226,7 +262,7 @@ private static function get_cache_date_key( $list_id ) { * @param string $list_id The List ID. * @return boolean */ - private static function is_cache_expired( $list_id ) { + private static function is_cache_expired( $list_id = null ) { $cache_date = get_option( self::get_cache_date_key( $list_id ) ); return $cache_date && ( time() - $cache_date ) > 20 * MINUTE_IN_SECONDS; } @@ -390,10 +426,15 @@ private static function get_data( $list_id ) { /** * Dispatches a new request to refresh the cache * - * @param string $list_id The List ID. + * @param string $list_id The List ID or null for the cache for all lists. * @return void */ - private static function dispatch_refresh( $list_id ) { + private static function dispatch_refresh( $list_id = null ) { + // If no list_id is provided, refresh the lists cache. + if ( ! $list_id ) { + self::fetch_lists(); + return; + } if ( ! function_exists( 'wp_create_nonce' ) ) { require_once ABSPATH . WPINC . '/pluggable.php'; @@ -484,38 +525,89 @@ private static function refresh_cached_data( $list_id ) { */ public static function handle_cron() { Newspack_Newsletters_Logger::log( 'Mailchimp cache: Handling cron request to refresh cache' ); + $lists = self::get_lists(); + + foreach ( $lists as $list ) { + Newspack_Newsletters_Logger::log( 'Mailchimp cache: Dispatching request to refresh cache for list ' . $list['id'] ); + self::dispatch_refresh( $list['id'] ); + } + } + + /** + * Fetches all audiences (lists) from the Mailchimp server + * + * @param int|null $limit (Optional) The maximum number of items to return. If not given, will get all items. + * + * @throws Exception In case of errors while fetching data from the server. + * @return array|WP_Error The audiences, or WP_Error if there was an error. + */ + public static function fetch_lists( $limit = null ) { $mc = self::get_mc_api(); if ( \is_wp_error( $mc ) ) { - return; + return []; } $lists_response = ( self::get_mc_instance() )->validate( $mc->get( 'lists', [ - 'count' => 1000, - 'fields' => [ 'id' ], + 'count' => $limit ?? 1000, + 'fields' => 'lists.name,lists.id,lists.web_id,lists.stats.member_count', ] ), __( 'Error retrieving Mailchimp lists.', 'newspack_newsletters' ) ); if ( is_wp_error( $lists_response ) || empty( $lists_response['lists'] ) ) { - return; + Newspack_Newsletters_Logger::log( 'Mailchimp cache: Error refreshing cache: ' . ( $lists_response->getMessage() ?? __( 'Error retrieving Mailchimp lists.', 'newspack_newsletters' ) ) ); + return is_wp_error( $lists_response ) ? $lists_response : []; } - foreach ( $lists_response['lists'] as $list ) { - Newspack_Newsletters_Logger::log( 'Mailchimp cache: Dispatching request to refresh cache for list ' . $list['id'] ); - self::dispatch_refresh( $list['id'] ); + // Cache the lists (only if we got them all). + if ( ! $limit ) { + update_option( self::get_lists_cache_key(), $lists_response['lists'], false ); // auto-load false. + update_option( self::get_cache_date_key(), time(), false ); // auto-load false. } + + return $lists_response['lists']; } /** - * Fetches the segments for a given List from the Mailchimp server + * Fetches a single segment by segment ID + list ID. * + * @param string $segment_id The segment ID. * @param string $list_id The audience (list) ID. + * + * @throws Exception In case of errors while fetching data from the server. + * @return array The audience segment + */ + public static function fetch_segment( $segment_id, $list_id ) { + $mc = self::get_mc_api(); + if ( \is_wp_error( $mc ) ) { + return $mc; + } + $response = ( self::get_mc_instance() )->validate( + $mc->get( + "lists/$list_id/segment/$segment_id", + [ + 'fields' => 'id,name,member_count,type,options,list_id', + ], + 60 + ), + __( 'Error retrieving Mailchimp segment with ID: ', 'newspack_newsletters' ) . $segment_id + ); + + return $response; + } + + /** + * Fetches the segments for a given List from the Mailchimp server + * + * @param string $list_id The audience (list) ID. + * @param int|null $limit (Optional) The maximum number of items to return. If not given, will get all items. + * * @throws Exception In case of errors while fetching data from the server. * @return array The audience segments */ - public static function fetch_segments( $list_id ) { + public static function fetch_segments( $list_id, $limit = null ) { $segments = []; $mc = self::get_mc_api(); @@ -528,7 +620,7 @@ public static function fetch_segments( $list_id ) { "lists/$list_id/segments", [ 'type' => 'saved', // 'saved' or 'static' segments. 'static' segments are actually the same thing as tags, so we can exclude them from this request as we fetch tags separately. - 'count' => 1000, + 'count' => $limit ?? 1000, ], 60 ), @@ -542,17 +634,19 @@ public static function fetch_segments( $list_id ) { /** * Fetches the interest_categories (aka Groups) for a given List from the Mailchimp server * - * @param string $list_id The audience (list) ID. + * @param string $list_id The audience (list) ID. + * @param int|null $limit (Optional) The maximum number of items to return. If not given, will get all items. + * * @throws Exception In case of errors while fetching data from the server. * @return array The audience interest_categories */ - private static function fetch_interest_categories( $list_id ) { + private static function fetch_interest_categories( $list_id, $limit = null ) { $mc = self::get_mc_api(); if ( \is_wp_error( $mc ) ) { return []; } $interest_categories = $list_id ? ( self::get_mc_instance() )->validate( - $mc->get( "lists/$list_id/interest-categories", [ 'count' => 1000 ], 60 ), + $mc->get( "lists/$list_id/interest-categories", [ 'count' => $limit ?? 1000 ], 60 ), __( 'Error retrieving Mailchimp groups.', 'newspack_newsletters' ) ) : null; @@ -560,7 +654,7 @@ private static function fetch_interest_categories( $list_id ) { foreach ( $interest_categories['categories'] as &$category ) { $category_id = $category['id']; $category['interests'] = ( self::get_mc_instance() )->validate( - $mc->get( "lists/$list_id/interest-categories/$category_id/interests", [ 'count' => 1000 ], 60 ), + $mc->get( "lists/$list_id/interest-categories/$category_id/interests", [ 'count' => $limit ?? 1000 ], 60 ), __( 'Error retrieving Mailchimp groups.', 'newspack_newsletters' ) ); } @@ -572,11 +666,13 @@ private static function fetch_interest_categories( $list_id ) { /** * Fetches the tags for a given audience (list) from the Mailchimp server * - * @param string $list_id The audience (list) ID. + * @param string $list_id The audience (list) ID. + * @param int|null $limit (Optional) The maximum number of items to return. If not given, will get all items. + * * @throws Exception In case of errors while fetching data from the server. * @return array The audience tags */ - public static function fetch_tags( $list_id ) { + public static function fetch_tags( $list_id, $limit = null ) { $mc = self::get_mc_api(); if ( \is_wp_error( $mc ) ) { return []; @@ -586,7 +682,7 @@ public static function fetch_tags( $list_id ) { "lists/$list_id/segments", [ 'type' => 'static', // 'saved' or 'static' segments. Tags are called 'static' segments in Mailchimp's API. - 'count' => 1000, + 'count' => $limit ?? 1000, ], 60 ), diff --git a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-controller.php b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-controller.php index 48deb7c7a..44ebbd7c0 100644 --- a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-controller.php +++ b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-controller.php @@ -42,14 +42,10 @@ public static function register_meta() { ); \register_meta( 'post', - 'mc_list_id', + 'mc_folder_id', [ 'object_subtype' => Newspack_Newsletters::NEWSPACK_NEWSLETTERS_CPT, - 'show_in_rest' => [ - 'schema' => [ - 'context' => [ 'edit' ], - ], - ], + 'show_in_rest' => true, 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', @@ -67,11 +63,11 @@ public function register_routes() { \register_rest_route( $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)', + '(?P[\a-z]+)/retrieve', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'api_retrieve' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], 'args' => [ 'id' => [ 'sanitize_callback' => 'absint', @@ -86,7 +82,7 @@ public function register_routes() { [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ $this, 'api_test' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_authoring_permissions_check' ], 'args' => [ 'id' => [ 'sanitize_callback' => 'absint', @@ -98,84 +94,6 @@ public function register_routes() { ], ] ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/sender', - [ - 'methods' => \WP_REST_Server::EDITABLE, - 'callback' => [ $this, 'api_sender' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], - 'args' => [ - 'id' => [ - 'sanitize_callback' => 'absint', - 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], - ], - 'from_name' => [ - 'sanitize_callback' => 'sanitize_text_field', - ], - 'reply_to' => [ - 'sanitize_callback' => 'sanitize_email', - ], - ], - ] - ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/folder', - [ - 'methods' => \WP_REST_Server::EDITABLE, - 'callback' => [ $this, 'api_folder' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], - 'args' => [ - 'id' => [ - 'sanitize_callback' => 'absint', - 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], - ], - 'list_id' => [ - 'sanitize_callback' => 'esc_attr', - ], - 'folder_id' => [ - 'sanitize_callback' => 'esc_attr', - ], - ], - ] - ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/list/(?P[\a-z]+)', - [ - 'methods' => \WP_REST_Server::EDITABLE, - 'callback' => [ $this, 'api_list' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], - 'args' => [ - 'id' => [ - 'sanitize_callback' => 'absint', - 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], - ], - 'list_id' => [ - 'sanitize_callback' => 'esc_attr', - ], - ], - ] - ); - \register_rest_route( - $this->service_provider::BASE_NAMESPACE . $this->service_provider->service, - '(?P[\a-z]+)/segments', - [ - 'methods' => \WP_REST_Server::EDITABLE, - 'callback' => [ $this, 'api_segments' ], - 'permission_callback' => [ $this->service_provider, 'api_authoring_permissions_check' ], - 'args' => [ - 'id' => [ - 'sanitize_callback' => 'absint', - 'validate_callback' => [ 'Newspack_Newsletters', 'validate_newsletter_id' ], - ], - 'target_id' => [ - 'sanitize_callback' => 'esc_attr', - ], - ], - ] - ); } /** @@ -207,61 +125,4 @@ public function api_test( $request ) { ); return self::get_api_response( $response ); } - - /** - * Set the sender name and email for the campaign. - * - * @param WP_REST_Request $request API request object. - * @return WP_REST_Response|mixed API response or error. - */ - public function api_sender( $request ) { - $response = $this->service_provider->sender( - $request['id'], - $request['from_name'], - $request['reply_to'] - ); - return self::get_api_response( $response ); - } - - /** - * Set folder for a campaign. - * - * @param WP_REST_Request $request API request object. - * @return WP_REST_Response|mixed API response or error. - */ - public function api_folder( $request ) { - $response = $this->service_provider->folder( - $request['id'], - $request['folder_id'] - ); - return self::get_api_response( $response ); - } - - /** - * Set list for a campaign. - * - * @param WP_REST_Request $request API request object. - * @return WP_REST_Response|mixed API response or error. - */ - public function api_list( $request ) { - $response = $this->service_provider->list( - $request['id'], - $request['list_id'] - ); - return self::get_api_response( $response ); - } - - /** - * Set Mailchimp audience segments for a campaign. - * - * @param WP_REST_Request $request API request object. - * @return WP_REST_Response|mixed API response or error. - */ - public function api_segments( $request ) { - $response = $this->service_provider->audience_segments( - $request['id'], - $request['target_id'] - ); - return self::get_api_response( $response ); - } } diff --git a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp.php b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp.php index 51b41ac31..dbdf7a9b0 100644 --- a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp.php +++ b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp.php @@ -10,6 +10,8 @@ use DrewM\MailChimp\MailChimp; use Newspack\Newsletters\Subscription_List; use Newspack\Newsletters\Subscription_Lists; +use Newspack\Newsletters\Send_Lists; +use Newspack\Newsletters\Send_List; /** * Main Newspack Newsletters Class. @@ -19,18 +21,18 @@ final class Newspack_Newsletters_Mailchimp extends \Newspack_Newsletters_Service use Newspack_Newsletters_Mailchimp_Groups; /** - * Whether the provider has support to tags and tags based Subscription Lists. + * Provider name. * - * @var boolean + * @var string */ - public static $support_local_lists = true; + public $name = 'Mailchimp'; /** - * Provider name. + * Whether the provider has support to tags and tags based Subscription Lists. * - * @var string + * @var boolean */ - public $name = 'Mailchimp'; + public static $support_local_lists = true; /** * Cache of contact added on execution. Control to avoid adding the same @@ -111,6 +113,20 @@ public function api_key() { return $credentials['api_key']; } + /** + * Get the base URL for the Mailchimp admin dashboard. + * + * @return string|boolean The URL on success. False on failure. + */ + public function get_admin_url() { + $api_key = $this->api_key(); + if ( strpos( $api_key, '-' ) === false ) { + return false; + } + list(, $data_center) = explode( '-', $api_key ); + return 'https://' . $data_center . '.admin.mailchimp.com/'; + } + /** * Set the API credentials for the service provider. * @@ -315,6 +331,15 @@ public function remove_tag_from_contact( $email, $tag, $list_id = null ) { ); } + /** + * Get available campaign folders. + * + * @return array|WP_Error List of folders or error. + */ + public function get_folders() { + return Newspack_Newsletters_Mailchimp_Cached_Data::get_folders(); + } + /** * Set folder for a campaign. * @@ -322,7 +347,7 @@ public function remove_tag_from_contact( $email, $tag, $list_id = null ) { * @param string $folder_id ID of the folder. * @return object|WP_Error API API Response or error. */ - public function folder( $post_id, $folder_id ) { + public function folder( $post_id, $folder_id = '' ) { $mc_campaign_id = get_post_meta( $post_id, 'mc_campaign_id', true ); if ( ! $mc_campaign_id ) { return new WP_Error( @@ -399,44 +424,180 @@ public function list( $post_id, $list_id ) { } } + /** + * Wrapper for fetching campaign from MC API. + * + * @param string $mc_campaign_id Campaign ID. + * @return object|WP_Error API Response or error. + */ + private function fetch_synced_campaign( $mc_campaign_id ) { + try { + $mc = new Mailchimp( $this->api_key() ); + $campaign = $this->validate( + $mc->get( + "campaigns/$mc_campaign_id", + [ + 'fields' => 'id,web_id,type,status,emails_sent,content_type,recipients,settings', + ] + ), + __( 'Error retrieving Mailchimp campaign.', 'newspack_newsletters' ) + ); + return $campaign; + } catch ( Exception $e ) { + return new WP_Error( + 'newspack_newsletters_mailchimp_error', + $e->getMessage() + ); + } + } + + /** + * Given a campaign object from the ESP or legacy newsletterData, extract sender and send-to info. + * + * @param array $newsletter_data Newsletter data from the ESP. + * @return array { + * Extracted sender and send-to info. All keys are optional and will be + * returned only if found in the campaign data. + * + * @type string $senderName Sender name. + * @type string $senderEmail Sender email. + * @type string $list_id List ID. + * @type string $sublist_id Sublist ID. + * } + */ + public function extract_campaign_info( $newsletter_data ) { + $campaign_info = [];if ( empty( $newsletter_data['campaign'] ) ) { + return $campaign_info; + } + $campaign = $newsletter_data['campaign']; + + // Sender info. + if ( ! empty( $campaign['settings']['from_name'] ) ) { + $campaign_info['senderName'] = $campaign['settings']['from_name']; + } + if ( ! empty( $campaign['settings']['reply_to'] ) ) { + $campaign_info['senderEmail'] = $campaign['settings']['reply_to']; + } + + // Audience. + if ( ! empty( $campaign['recipients']['list_id'] ) ) { + $campaign_info['list_id'] = $campaign['recipients']['list_id']; + } + + // Group, segment, or tag. + if ( ! empty( $campaign['recipients']['segment_opts'] ) ) { + $segment_opts = $campaign['recipients']['segment_opts']; + $target_id_raw = $segment_opts['saved_segment_id'] ?? null; + if ( ! $target_id_raw ) { + $target_id_raw = $segment_opts['conditions'][0]['value'] ?? null; + } + if ( $target_id_raw ) { + $target_id = strval( is_array( $target_id_raw ) && ! empty( $target_id_raw[0] ) ? $target_id_raw[0] : $target_id_raw ); + if ( ! $target_id ) { + $target_id = (string) $target_id_raw; + } + if ( $target_id ) { + $campaign_info['sublist_id'] = $target_id; + } + } + } + + return $campaign_info; + } + /** * Retrieve a campaign. * * @param integer $post_id Numeric ID of the Newsletter post. * @return object|WP_Error API Response or error. + * @throws Exception Error message. */ public function retrieve( $post_id ) { - if ( ! $this->has_api_credentials() ) { - return []; - } try { + if ( ! $this->has_api_credentials() ) { + throw new Exception( esc_html__( 'Missing or invalid Mailchimp credentials.', 'newspack-newsletters' ) ); + } $mc_campaign_id = get_post_meta( $post_id, 'mc_campaign_id', true ); + + // If there's no synced campaign ID yet, create it. if ( ! $mc_campaign_id ) { - $this->sync( get_post( $post_id ) ); - } - $mc = new Mailchimp( $this->api_key() ); - $campaign = $this->validate( - $mc->get( "campaigns/$mc_campaign_id" ), - __( 'Error retrieving Mailchimp campaign.', 'newspack_newsletters' ) - ); - $list_id = $campaign && isset( $campaign['recipients']['list_id'] ) ? $campaign['recipients']['list_id'] : null; + Newspack_Newsletters_Logger::log( 'Creating new campaign for post ID ' . $post_id ); + $sync_result = $this->sync( get_post( $post_id ) ); + if ( is_wp_error( $sync_result ) ) { + throw new Exception( wp_kses_post( $sync_result->get_error_message() ) ); + } + $campaign = $sync_result['campaign_result']; + $mc_campaign_id = $campaign['id']; + } else { + Newspack_Newsletters_Logger::log( 'Retrieving campaign ' . $mc_campaign_id . ' for post ID ' . $post_id ); + $campaign = $this->fetch_synced_campaign( $mc_campaign_id ); - $lists = $this->get_lists( true ); - if ( \is_wp_error( $lists ) ) { - return $lists; + // If we couldn't get the campaign, delete the mc_campaign_id so it gets recreated on the next sync. + if ( is_wp_error( $campaign ) ) { + delete_post_meta( $post_id, 'mc_campaign_id' ); + throw new Exception( wp_kses_post( $campaign->get_error_message() ) ); + } } + $campaign_info = $this->extract_campaign_info( [ 'campaign' => $campaign ] ); + $list_id = $campaign_info['list_id'] ?? null; + $send_list_id = get_post_meta( $post_id, 'send_list_id', true ); + $send_sublist_id = get_post_meta( $post_id, 'send_sublist_id', true ); $newsletter_data = [ - 'campaign' => $campaign, - 'campaign_id' => $mc_campaign_id, - 'folders' => Newspack_Newsletters_Mailchimp_Cached_Data::get_folders(), - 'interest_categories' => $this->get_interest_categories( $list_id ), - 'lists' => $lists, - 'merge_fields' => $list_id ? Newspack_Newsletters_Mailchimp_Cached_Data::get_merge_fields( $list_id ) : [], - 'segments' => $list_id ? Newspack_Newsletters_Mailchimp_Cached_Data::get_segments( $list_id ) : [], - 'tags' => $this->get_tags( $list_id ), + 'campaign' => $campaign, + 'campaign_id' => $mc_campaign_id, + 'folders' => Newspack_Newsletters_Mailchimp_Cached_Data::get_folders(), + 'allowed_sender_domains' => $this->get_verified_domains(), + 'merge_fields' => $list_id ? Newspack_Newsletters_Mailchimp_Cached_Data::get_merge_fields( $list_id ) : [], + 'link' => sprintf( 'https://%s.admin.mailchimp.com/campaigns/edit?id=%d', explode( '-', $this->api_key() )[1], $campaign['web_id'] ), ]; + // Reconcile campaign settings with info fetched from the ESP for a true two-way sync. + if ( ! empty( $campaign_info['senderName'] ) && $campaign_info['senderName'] !== get_post_meta( $post_id, 'senderName', true ) ) { + $newsletter_data['senderName'] = $campaign_info['senderName']; // If campaign has different sender info set, update ours. + } + if ( ! empty( $campaign_info['senderEmail'] ) && $campaign_info['senderEmail'] !== get_post_meta( $post_id, 'senderEmail', true ) ) { + $newsletter_data['senderEmail'] = $campaign_info['senderEmail']; // If campaign has different sender info set, update ours. + } + if ( $list_id && $list_id !== $send_list_id ) { + $newsletter_data['list_id'] = $list_id; // If campaign has a different list selected, update ours. + $send_list_id = $list_id; + + if ( ! empty( $campaign_info['sublist_id'] ) && $campaign_info['sublist_id'] !== $send_sublist_id ) { + $newsletter_data['sublist_id'] = $campaign_info['sublist_id']; // If campaign has a different sublist selected, update ours. + $send_sublist_id = $campaign_info['sublist_id']; + } + } + + // Prefetch send list info if we have a selected list and/or sublist. + $send_lists = $this->get_send_lists( + [ + 'ids' => $send_list_id ? [ $send_list_id ] : null, // If we have a selected list, make sure to fetch it. + 'type' => 'list', + ], + true + ); + if ( is_wp_error( $send_lists ) ) { + throw new Exception( wp_kses_post( $send_lists->get_error_message() ) ); + } + $newsletter_data['lists'] = $send_lists; + + $send_sublists = $send_list_id || $send_sublist_id ? // Prefetch send lists only if we have something selected already. + $this->get_send_lists( + [ + 'ids' => [ $send_sublist_id ], // If we have a selected sublist, make sure to fetch it. Otherwise, we'll populate sublists later. + 'parent_id' => $send_list_id, + 'type' => 'sublist', + ], + true + ) : + []; + + if ( is_wp_error( $send_sublists ) ) { + throw new Exception( wp_kses_post( $send_sublists->get_error_message() ) ); + } + $newsletter_data['sublists'] = $send_sublists; + return $newsletter_data; } catch ( Exception $e ) { return new WP_Error( @@ -455,92 +616,181 @@ public function retrieve( $post_id ) { * @return array|WP_Error List of subscription lists or error. */ public function get_lists( $audiences_only = false ) { - try { - $mc = new Mailchimp( $this->api_key() ); - $lists_response = $this->validate( - $mc->get( - 'lists', - [ - 'count' => 1000, - ] - ), - __( 'Error retrieving Mailchimp lists.', 'newspack_newsletters' ) - ); - if ( is_wp_error( $lists_response ) ) { - return new WP_Error( - 'newspack_newsletters_mailchimp_error', - $lists_response->getMessage() - ); - } + $lists = Newspack_Newsletters_Mailchimp_Cached_Data::get_lists(); + if ( $audiences_only || is_wp_error( $lists ) ) { + return $lists; + } - if ( ! isset( $lists_response['lists'] ) ) { - $error_message = __( 'Error retrieving Mailchimp lists.', 'newspack_newsletters' ); - $error_message .= ! empty( $lists_response['title'] ) ? ' ' . $lists_response['title'] : ''; - return new WP_Error( - 'newspack_newsletters_mailchimp_error', - $error_message + // In addition to Audiences, we also automatically fetch all groups and tags and offer them as Subscription Lists. + // Build the final list inside the loop so groups are added after the list they belong to and we can then represent the hierarchy in the UI. + foreach ( $lists as $list ) { + + $lists[] = $list; + $all_categories = Newspack_Newsletters_Mailchimp_Cached_Data::get_interest_categories( $list['id'] ); + $all_categories = $all_categories['categories'] ?? []; + $all_tags = Newspack_Newsletters_Mailchimp_Cached_Data::get_tags( $list['id'] ) ?? []; + + foreach ( $all_categories as $found_category ) { + + // Do not include groups under the category we use to store "Local" lists. + if ( $this->get_group_category_name() === $found_category['title'] ) { + continue; + } + + $all_groups = $found_category['interests'] ?? []; + + $groups = array_map( + function ( $group ) use ( $list ) { + $group['id'] = Subscription_List::mailchimp_generate_public_id( $group['id'], $list['id'] ); + $group['type'] = 'mailchimp-group'; + return $group; + }, + $all_groups['interests'] ?? [] // Yes, two levels of 'interests'. ); + $lists = array_merge( $lists, $groups ); } - if ( $audiences_only ) { - return $lists_response['lists']; + foreach ( $all_tags as $tag ) { + $tag['id'] = Subscription_List::mailchimp_generate_public_id( $tag['id'], $list['id'], 'tag' ); + $tag['type'] = 'mailchimp-tag'; + $lists[] = $tag; } + } - $lists = []; + // Reconcile edited names for locally-configured lists. + $configured_lists = Newspack_Newsletters_Subscription::get_lists_config(); + if ( ! empty( $configured_lists ) ) { + foreach ( $lists as &$list ) { + if ( ! empty( $configured_lists[ $list['id'] ]['name'] ) ) { + $list['local_name'] = $configured_lists[ $list['id'] ]['name']; + } + } + } - // In addition to Audiences, we also automatically fetch all groups and tags and offer them as Subscription Lists. - // Build the final list inside the loop so groups are added after the list they belong to and we can then represent the hierarchy in the UI. - foreach ( $lists_response['lists'] as $list ) { + return $lists; + } - $lists[] = $list; - $all_categories = Newspack_Newsletters_Mailchimp_Cached_Data::get_interest_categories( $list['id'] ); - $all_categories = $all_categories['categories'] ?? []; - $all_tags = Newspack_Newsletters_Mailchimp_Cached_Data::get_tags( $list['id'] ) ?? []; + /** + * Get all applicable audiences, groups, tags, and segments as Send_List objects. + * + * @param array $args Array of search args. See Send_Lists::get_default_args() for supported params and default values. + * @param boolean $to_array If true, convert Send_List objects to arrays before returning. + * + * @return Send_List[]|array|WP_Error Array of Send_List objects or arrays on success, or WP_Error object on failure. + */ + public function get_send_lists( $args = [], $to_array = false ) { + $defaults = Send_Lists::get_default_args(); + $args = wp_parse_args( $args, $defaults ); + $by_id = ! empty( $args['ids'] ); + $admin_url = self::get_admin_url(); + $audiences = Newspack_Newsletters_Mailchimp_Cached_Data::get_lists( $args['limit'] ); + $send_lists = []; + + $entity_type = 'audience'; + foreach ( $audiences as $audience ) { + if ( ! empty( $args['parent_id'] ) && $audience['id'] !== $args['parent_id'] ) { + continue; + } + $matches = $by_id ? Send_Lists::matches_id( $args['ids'], $audience['id'] ) : Send_Lists::matches_search( $args['search'], [ $audience['id'], $audience['name'], $entity_type ] ); + if ( ( ! $args['type'] || 'list' === $args['type'] ) && $matches ) { + $config = [ + 'provider' => $this->service, + 'type' => 'list', + 'id' => $audience['id'], + 'name' => $audience['name'], + 'entity_type' => $entity_type, + 'count' => $audience['stats']['member_count'] ?? 0, + ]; + if ( $admin_url && ! empty( $audience['web_id'] ) ) { + $config['edit_link'] = $admin_url . 'audience/contacts/?id=' . $audience['web_id']; + } + $send_lists[] = new Send_List( $config ); + } - foreach ( $all_categories as $found_category ) { + if ( 'list' === $args['type'] ) { + continue; + } - // Do not include groups under the category we use to store "Local" lists. - if ( $this->get_group_category_name() === $found_category['title'] ) { - continue; + $groups = Newspack_Newsletters_Mailchimp_Cached_Data::get_interest_categories( $audience['id'], $args['limit'] ); + $entity_type = 'group'; + if ( isset( $groups['categories'] ) ) { + foreach ( $groups['categories'] as $category ) { + if ( isset( $category['interests']['interests'] ) ) { + foreach ( $category['interests']['interests'] as $interest ) { + $matches = $by_id ? Send_Lists::matches_id( $args['ids'], $interest['id'] ) : Send_Lists::matches_search( $args['search'], [ $interest['id'], $interest['name'], $entity_type ] ); + if ( $matches ) { + $config = [ + 'provider' => $this->service, + 'type' => 'sublist', + 'id' => $interest['id'], + 'name' => $interest['name'], + 'entity_type' => $entity_type, + 'parent' => $interest['list_id'], + 'count' => $interest['subscriber_count'], + ]; + if ( $admin_url && $audience['web_id'] ) { + $config['edit_link'] = $admin_url . 'audience/groups/?id=' . $audience['web_id']; + } + $send_lists[] = new Send_List( $config ); + } + } } - - $all_groups = $found_category['interests'] ?? []; - - $groups = array_map( - function ( $group ) use ( $list ) { - $group['id'] = Subscription_List::mailchimp_generate_public_id( $group['id'], $list['id'] ); - $group['type'] = 'mailchimp-group'; - return $group; - }, - $all_groups['interests'] ?? [] // Yes, two levels of 'interests'. - ); - $lists = array_merge( $lists, $groups ); } + } - foreach ( $all_tags as $tag ) { - $tag['id'] = Subscription_List::mailchimp_generate_public_id( $tag['id'], $list['id'], 'tag' ); - $tag['type'] = 'mailchimp-tag'; - $lists[] = $tag; + $tags = Newspack_Newsletters_Mailchimp_Cached_Data::get_tags( $audience['id'], $args['limit'] ); + $entity_type = 'tag'; + foreach ( $tags as $tag ) { + $matches = $by_id ? Send_Lists::matches_id( $args['ids'], $tag['id'] ) : Send_Lists::matches_search( $args['search'], [ $tag['id'], $tag['name'], $entity_type ] ); + if ( $matches ) { + $config = [ + 'provider' => $this->service, + 'type' => 'sublist', + 'id' => $tag['id'], + 'name' => $tag['name'], + 'entity_type' => $entity_type, + 'parent' => $tag['list_id'], + 'count' => $tag['member_count'], + ]; + if ( $admin_url && $audience['web_id'] ) { + $config['edit_link'] = $admin_url . 'audience/tags/?id=' . $audience['web_id']; + } + $send_lists[] = new Send_List( $config ); } } - // Reconcile edited names for locally-configured lists. - $configured_lists = Newspack_Newsletters_Subscription::get_lists_config(); - if ( ! empty( $configured_lists ) ) { - foreach ( $lists as &$list ) { - if ( ! empty( $configured_lists[ $list['id'] ]['name'] ) ) { - $list['local_name'] = $configured_lists[ $list['id'] ]['name']; + $segments = Newspack_Newsletters_Mailchimp_Cached_Data::get_segments( ( $parent_id ?? $audience['id'] ), $args['limit'] ); + $entity_type = 'segment'; + foreach ( $segments as $segment ) { + $matches = $by_id ? Send_Lists::matches_id( $args['ids'], $segment['id'] ) : Send_Lists::matches_search( $args['search'], [ $segment['id'], $segment['name'], $entity_type ] ); + if ( $matches ) { + $config = [ + 'provider' => $this->service, + 'type' => 'sublist', + 'id' => $segment['id'], + 'name' => $segment['name'], + 'entity_type' => $entity_type, + 'parent' => $segment['list_id'], + 'count' => $segment['member_count'], + ]; + if ( $admin_url && $audience['web_id'] ) { + $config['edit_link'] = $admin_url . 'audience/segments/?id=' . $audience['web_id']; } + $send_lists[] = new Send_List( $config ); } } + } - return $lists; - } catch ( Exception $e ) { - return new WP_Error( - 'newspack_newsletters_mailchimp_error', - $e->getMessage() + // Convert to arrays if requested. + if ( $to_array ) { + $send_lists = array_map( + function ( $list ) { + return $list->to_array(); + }, + $send_lists ); } + return $send_lists; } /** @@ -631,30 +881,19 @@ public function get_list_merge_fields( $list_id ) { } /** - * Set sender data. + * Get verified domains from the MC account. * - * @param string $post_id Numeric ID of the campaign. - * @param string $from_name Sender name. - * @param string $reply_to Reply to email address. - * @return object|WP_Error API Response or error. + * @return array List of verified domains. */ - public function sender( $post_id, $from_name, $reply_to ) { - $mc_campaign_id = get_post_meta( $post_id, 'mc_campaign_id', true ); - if ( ! $mc_campaign_id ) { - return new WP_Error( - 'newspack_newsletters_no_campaign_id', - __( 'Mailchimp campaign ID not found.', 'newspack-newsletters' ) - ); - } - try { - $mc = new Mailchimp( $this->api_key() ); - - $result = $this->validate( - $mc->get( 'verified-domains', [ 'count' => 1000 ] ), - __( 'Error retrieving verified domains from Mailchimp.', 'newspack-newsletters' ) - ); + public function get_verified_domains() { + $mc = new Mailchimp( $this->api_key() ); + $result = $this->validate( + $mc->get( 'verified-domains', [ 'count' => 1000 ] ), + __( 'Error retrieving verified domains from Mailchimp.', 'newspack-newsletters' ) + ); - $verified_domains = array_filter( + return array_values( + array_filter( array_map( function ( $domain ) { return $domain['verified'] ? strtolower( trim( $domain['domain'] ) ) : null; @@ -664,10 +903,21 @@ function ( $domain ) { function ( $domain ) { return ! empty( $domain ); } - ); + ) + ); + } - $explode = explode( '@', $reply_to ); - $domain = strtolower( trim( array_pop( $explode ) ) ); + /** + * Set sender data. + * + * @param string $email Reply to email address. + * @return boolean|WP_Error True if the email address is valid, otherwise error. + */ + public function validate_sender_email( $email ) { + try { + $verified_domains = $this->get_verified_domains(); + $explode = explode( '@', $email ); + $domain = strtolower( trim( array_pop( $explode ) ) ); if ( ! in_array( $domain, $verified_domains ) ) { return new WP_Error( @@ -681,25 +931,7 @@ function ( $domain ) { ); } - $settings = []; - if ( $from_name ) { - $settings['from_name'] = $from_name; - } - if ( $reply_to ) { - $settings['reply_to'] = $reply_to; - } - $payload = [ - 'settings' => $settings, - ]; - $result = $this->validate( - $mc->patch( "campaigns/$mc_campaign_id", $payload ), - __( 'Error setting sender name and email.', 'newspack_newsletters' ) - ); - - $data = $this->retrieve( $post_id ); - $data['result'] = $result; - - return \rest_ensure_response( $data ); + return true; } catch ( Exception $e ) { return new WP_Error( 'newspack_newsletters_mailchimp_error', @@ -767,6 +999,110 @@ public function test( $post_id, $emails ) { } } + /** + * Get a payload for syncing post data to the ESP campaign. + * + * @param WP_Post|int $post Post object or ID. + * @return object Payload for syncing. + */ + public function get_sync_payload( $post ) { + if ( is_int( $post ) ) { + $post = get_post( $post ); + } + $payload = [ + 'type' => 'regular', + 'content_type' => 'template', + 'settings' => [ + 'subject_line' => $post->post_title, + 'title' => $this->get_campaign_name( $post ), + ], + ]; + + // Sync sender name + email. + $sender_name = get_post_meta( $post->ID, 'senderName', true ); + $sender_email = get_post_meta( $post->ID, 'senderEmail', true ); + if ( ! empty( $sender_name ) ) { + $payload['settings']['from_name'] = $sender_name; + } + if ( ! empty( $sender_email ) ) { + $is_valid_email = $this->validate_sender_email( $sender_email ); + if ( is_wp_error( $is_valid_email ) ) { + delete_post_meta( $post->ID, 'senderEmail' ); // Delete invalid email so we can't accidentally attempt to send with it. + return $is_valid_email; + } + $payload['settings']['reply_to'] = $sender_email; + } + + // Sync send-to selections. + $send_list_id = get_post_meta( $post->ID, 'send_list_id', true ); + if ( ! empty( $send_list_id ) ) { + $payload['recipients'] = [ + 'list_id' => $send_list_id, + ]; + $send_sublist_id = get_post_meta( $post->ID, 'send_sublist_id', true ); + if ( ! empty( $send_sublist_id ) ) { + $sublist = $this->get_send_lists( + [ + 'ids' => [ $send_sublist_id ], + 'limit' => 1, + 'parent_id' => $send_list_id, + 'type' => 'sublist', + ] + ); + if ( ! empty( $sublist[0]->get_entity_type() ) ) { + $sublist_type = $sublist[0]->get_entity_type(); + switch ( $sublist_type ) { + case 'group': + $payload['recipients']['segment_opts'] = [ + 'match' => 'all', + 'conditions' => [ + [ + 'condition_type' => 'Interests', + 'field' => 'interests-' . $send_sublist_id, + 'op' => 'interestcontains', + 'value' => [ $send_sublist_id ], + ], + ], + ]; + break; + case 'tag': + $payload['recipients']['segment_opts'] = [ + 'match' => 'all', + 'conditions' => [ + [ + 'condition_type' => 'StaticSegment', + 'field' => 'static_segment', + 'op' => 'static_is', + 'value' => $send_sublist_id, + ], + ], + ]; + break; + case 'segment': + $segment_data = Newspack_Newsletters_Mailchimp_Cached_Data::fetch_segment( $send_sublist_id, $send_list_id ); + if ( is_wp_error( $segment_data ) ) { + return $segment_data; + } + if ( ! empty( $segment_data['options'] ) ) { + $payload['recipients']['segment_opts'] = $segment_data['options']; + } else { + return new WP_Error( 'newspack_newsletters_mailchimp_error', __( 'Could not fetch segment criteria for segment ', 'newspack-newsletters' ) . $sublist['name'] ); + } + break; + } + } + } + } + + // Sync folder selection. + $folder_id = get_post_meta( $post->ID, 'mc_folder_id', true ); + if ( $folder_id ) { + $payload['settings']['folder_id'] = $folder_id; + } + + return $payload; + } + /** * Synchronize post with corresponding ESP campaign. * @@ -783,21 +1119,14 @@ public function sync( $post ) { try { $api_key = $this->api_key(); if ( ! $api_key ) { - throw new Exception( __( 'No Mailchimp API key available.', 'newspack-newsletters' ) ); + throw new Exception( __( 'Missing or invalid Mailchimp credentials.', 'newspack-newsletters' ) ); } if ( empty( $post->post_title ) ) { throw new Exception( __( 'The newsletter subject cannot be empty.', 'newspack-newsletters' ) ); } $mc = new Mailchimp( $api_key ); - $payload = [ - 'type' => 'regular', - 'content_type' => 'template', - 'settings' => [ - 'subject_line' => $post->post_title, - 'title' => $this->get_campaign_name( $post ), - ], - ]; $mc_campaign_id = get_post_meta( $post->ID, 'mc_campaign_id', true ); + $payload = $this->get_sync_payload( $post ); /** * Filter the metadata payload sent to Mailchimp when syncing. @@ -810,15 +1139,20 @@ public function sync( $post ) { */ $payload = apply_filters( 'newspack_newsletters_mc_payload_sync', $payload, $post, $mc_campaign_id ); + // If we have any errors in the payload, throw an exception. + if ( is_wp_error( $payload ) ) { + throw new Exception( esc_html( $payload->get_error_message() ) ); + } + if ( $mc_campaign_id ) { $campaign_result = $this->validate( $mc->patch( "campaigns/$mc_campaign_id", $payload ), - __( 'Error updating campaign title.', 'newspack_newsletters' ) + __( 'Error updating existing campaign draft.', 'newspack_newsletters' ) ); } else { $campaign_result = $this->validate( $mc->post( 'campaigns', $payload ), - __( 'Error setting campaign title.', 'newspack_newsletters' ) + __( 'Error creating campaign.', 'newspack_newsletters' ) ); $mc_campaign_id = $campaign_result['id']; update_post_meta( $post->ID, 'mc_campaign_id', $mc_campaign_id ); @@ -838,19 +1172,12 @@ public function sync( $post ) { $mc->put( "campaigns/$mc_campaign_id/content", $content_payload ), __( 'Error updating campaign content.', 'newspack_newsletters' ) ); - - // Retrieve and store campaign data. - $data = $this->retrieve( $post->ID ); - if ( ! is_wp_error( $data ) ) { - update_post_meta( $post->ID, 'newsletterData', $data ); - } - return [ 'campaign_result' => $campaign_result, 'content_result' => $content_result, ]; } catch ( Exception $e ) { - set_transient( $transient_name, __( 'Error syncing with ESP. ', 'newspack-newsletters' ) . $e->getMessage(), 45 ); + set_transient( $transient_name, 'Mailchimp: ' . $e->getMessage(), 45 ); return new WP_Error( 'newspack_newsletters_mailchimp_error', $e->getMessage() ); } } @@ -1752,8 +2079,10 @@ public static function get_labels( $context = '' ) { 'name' => 'Mailchimp', // The provider name. 'list' => __( 'audience', 'newspack-newsletters' ), // "list" in lower case singular format. 'lists' => __( 'audiences', 'newspack-newsletters' ), // "list" in lower case plural format. + 'sublist' => __( 'group, segment, or tag', 'newspack-newsletters' ), // Sublist entities in lowercase singular format. 'List' => __( 'Audience', 'newspack-newsletters' ), // "list" in uppercase case singular format. 'Lists' => __( 'Audiences', 'newspack-newsletters' ), // "list" in uppercase case plural format. + 'Sublist' => __( 'Group, Segment, or Tag', 'newspack-newsletters' ), // Sublist entities in uppercase singular format. 'list_explanation' => __( 'Mailchimp Audience', 'newspack-newsletters' ), // translators: %s is the name of the group category. "Newspack newsletters" by default. 'local_list_explanation' => sprintf( __( 'Mailchimp Group under the %s category', 'newspack-newsletters' ), self::get_group_category_name() ), diff --git a/newspack-newsletters.php b/newspack-newsletters.php index 5ffc44704..53145849f 100644 --- a/newspack-newsletters.php +++ b/newspack-newsletters.php @@ -76,3 +76,4 @@ // This MUST be initialized after Newspack_Newsletter class. \Newspack\Newsletters\Subscription_Lists::init(); +\Newspack\Newsletters\Send_Lists::init(); diff --git a/src/components/init-modal/screens/layout-picker/index.js b/src/components/init-modal/screens/layout-picker/index.js index 88aed3c3e..ef3dbe3d3 100644 --- a/src/components/init-modal/screens/layout-picker/index.js +++ b/src/components/init-modal/screens/layout-picker/index.js @@ -8,7 +8,7 @@ import { find } from 'lodash'; * WordPress dependencies */ import { parse } from '@wordpress/blocks'; -import { Fragment, useState, useEffect } from '@wordpress/element'; +import { useState, useEffect } from '@wordpress/element'; import { useDispatch } from '@wordpress/data'; import { Button, Spinner } from '@wordpress/components'; import { __ } from '@wordpress/i18n'; @@ -39,8 +39,8 @@ export default function LayoutPicker() { const insertLayout = layoutId => { const { post_content, meta = {} } = find( layouts, { ID: layoutId } ) || {}; - if ( meta.layout_defaults !== undefined ) { - meta.stringifiedLayoutDefaults = meta.layout_defaults; + if ( meta.campaign_defaults && 'string' === typeof meta.campaign_defaults ) { + meta.stringifiedCampaignDefaults = meta.campaign_defaults; } editPost( { meta: { template_id: layoutId, ...meta } } ); resetEditorBlocks( post_content ? parse( post_content ) : [] ); @@ -60,7 +60,7 @@ export default function LayoutPicker() { }, [ layouts.length ] ); return ( - + <>
@@ -136,6 +136,6 @@ export default function LayoutPicker() { { __( 'Use Selected Layout', 'newspack-newsletters' ) }
- + ); } diff --git a/src/components/send-button/index.js b/src/components/send-button/index.js index 26b21fa54..651368e53 100644 --- a/src/components/send-button/index.js +++ b/src/components/send-button/index.js @@ -19,6 +19,7 @@ import { get } from 'lodash'; */ import { getServiceProvider } from '../../service-providers'; import { refreshEmailHtml, validateNewsletter } from '../../newsletter-editor/utils'; +import { useNewsletterData } from '../../newsletter-editor/store'; import './style.scss'; function PreviewHTML() { @@ -111,7 +112,7 @@ export default compose( [ const { editPost, savePost } = dispatch( 'core/editor' ); return { editPost, savePost }; } ), - withSelect( ( select, { forceIsDirty } ) => { + withSelect( ( select ) => { const { didPostSaveRequestSucceed, getCurrentPost, @@ -125,7 +126,7 @@ export default compose( [ isCurrentPostPublished, } = select( 'core/editor' ); return { - isPublishable: forceIsDirty || isEditedPostPublishable(), + isPublishable: isEditedPostPublishable(), isSaveable: isEditedPostSaveable(), status: getEditedPostAttribute( 'status' ), isSaving: isSavingPost(), @@ -164,9 +165,10 @@ export default compose( [ } }, [ saveDidSucceed ] ); - const { newsletterData = {}, is_public } = meta; + const { is_public } = meta; + const newsletterData = useNewsletterData(); - const newsletterValidationErrors = validateNewsletter( newsletterData ); + const newsletterValidationErrors = validateNewsletter( meta ); const { name: serviceProviderName, @@ -340,7 +342,7 @@ export default compose( [ /> ) }
- { renderPreSendInfo( newsletterData ) } + { renderPreSendInfo( newsletterData, meta ) }
+

+ ); +} diff --git a/src/newsletter-editor/editor/index.js b/src/newsletter-editor/editor/index.js index 96b92d5d6..248586ded 100644 --- a/src/newsletter-editor/editor/index.js +++ b/src/newsletter-editor/editor/index.js @@ -26,16 +26,12 @@ const Editor = compose( [ withApiHandler(), withSelect( select => { const { - getCurrentPostAttribute, getEditedPostAttribute, - isCleanNewPost, - isCurrentPostPublished, } = select( 'core/editor' ); - const { getActiveGeneralSidebarName, getAllMetaBoxes } = select( 'core/edit-post' ); + const { getAllMetaBoxes } = select( 'core/edit-post' ); const { getSettings } = select( 'core/block-editor' ); const meta = getEditedPostAttribute( 'meta' ); - const status = getCurrentPostAttribute( 'status' ); - const sent = getCurrentPostAttribute( 'meta' ).newsletter_sent; + const sent = meta.newsletter_sent; const settings = getSettings(); const experimentalSettingsColors = get( settings, [ '__experimentalFeatures', @@ -45,22 +41,15 @@ const Editor = compose( [ ] ); const colors = settings.colors || experimentalSettingsColors || []; - const newsletterValidationErrors = validateNewsletter( meta.newsletterData ); - return { - isCleanNewPost: isCleanNewPost(), - isPublished: isCurrentPostPublished(), - isReady: newsletterValidationErrors.length === 0, - activeSidebarName: getActiveGeneralSidebarName(), html: meta[ newspack_email_editor_data.email_html_meta ], colorPalette: colors.reduce( ( _colors, { slug, color } ) => ( { ..._colors, [ slug ]: color } ), {} ), - status, + meta, sent, isPublic: meta.is_public, - campaignName: meta.campaign_name, newsletterSendErrors: meta.newsletter_send_errors, isCustomFieldsMetaBoxActive: getAllMetaBoxes().some( box => box.id === 'postcustom' ), }; @@ -84,7 +73,6 @@ const Editor = compose( [ createNotice, removeNotice, openModal, - updateMetaValue: ( key, value ) => editPost( { meta: { [ key ]: value } } ), }; } ), ] )( @@ -95,9 +83,9 @@ const Editor = compose( [ html, isCustomFieldsMetaBoxActive, isPublic, - isReady, lockPostAutosaving, lockPostSaving, + meta, newsletterSendErrors, openModal, removeNotice, @@ -106,6 +94,8 @@ const Editor = compose( [ successNote, } ) => { const [ publishEl ] = useState( document.createElement( 'div' ) ); + const newsletterValidationErrors = validateNewsletter( meta ); + const isReady = newsletterValidationErrors.length === 0; useEffect( () => { // Create alternate publish button. @@ -113,14 +103,6 @@ const Editor = compose( [ 'editor-post-publish-button__button' )[ 0 ]; publishButton.parentNode.insertBefore( publishEl, publishButton ); - - // Show async error messages. - if ( newspack_email_editor_data?.error_message ) { - createNotice( 'error', newspack_email_editor_data.error_message, { - id: 'newspack-newsletters-newsletter-async-error', - isDismissible: true, - } ); - } }, [] ); // Set color palette option. diff --git a/src/newsletter-editor/index.js b/src/newsletter-editor/index.js index 0160eabe9..1cd70f6b6 100644 --- a/src/newsletter-editor/index.js +++ b/src/newsletter-editor/index.js @@ -8,6 +8,7 @@ import { PluginDocumentSettingPanel, PluginSidebar, PluginSidebarMoreMenuItem, + PluginPostStatusInfo, } from '@wordpress/edit-post'; import { registerPlugin } from '@wordpress/plugins'; import { styles } from '@wordpress/icons'; @@ -24,16 +25,23 @@ import { Styling, ApplyStyling } from './styling/'; import { PublicSettings } from './public'; import registerEditorPlugin from './editor/'; import withApiHandler from '../components/with-api-handler'; +import { registerStore, fetchNewsletterData, useNewsletterDataError } from './store'; +import { isSupportedESP } from './utils'; +import CampaignLink from './campaign-link'; import './debug-send'; +registerStore(); registerEditorPlugin(); -function NewsletterEdit( { apiFetchWithErrorHandling, setInFlightForAsync } ) { - const layoutId = useSelect( - select => select( 'core/editor' ).getEditedPostAttribute( 'meta' ).template_id - ); - const savePost = useDispatch( 'core/editor' ).savePost; - +function NewsletterEdit( { apiFetchWithErrorHandling, setInFlightForAsync, inFlight } ) { + const { layoutId, postId } = useSelect( select => { + const { getCurrentPostId, getEditedPostAttribute } = select( 'core/editor' ); + const meta = getEditedPostAttribute( 'meta' ); + return { + layoutId: meta.template_id, + postId: getCurrentPostId(), + }; + } ); const [ shouldDisplaySettings, setShouldDisplaySettings ] = useState( window?.newspack_newsletters_data?.is_service_provider_configured !== '1' ); @@ -42,24 +50,35 @@ function NewsletterEdit( { apiFetchWithErrorHandling, setInFlightForAsync } ) { ); const [ isConnected, setIsConnected ] = useState( null ); const [ oauthUrl, setOauthUrl ] = useState( null ); - + const newsletterDataError = useNewsletterDataError(); + const savePost = useDispatch( 'core/editor' ).savePost; + const { createNotice, removeNotice } = useDispatch( 'core/notices' ); const { name: serviceProviderName, hasOauth } = getServiceProvider(); const verifyToken = () => { - const params = { - path: `/newspack-newsletters/v1/${ serviceProviderName }/verify_token`, - method: 'GET', - }; - setInFlightForAsync(); - apiFetchWithErrorHandling( params ).then( async response => { - if ( false === isConnected && true === response.valid ) { - savePost(); - } - setOauthUrl( response.auth_url ); - setIsConnected( response.valid ); - } ); + if ( isSupportedESP() && hasOauth ) { + const params = { + path: `/newspack-newsletters/v1/${ serviceProviderName }/verify_token`, + method: 'GET', + }; + setInFlightForAsync(); + apiFetchWithErrorHandling( params ).then( async response => { + if ( false === isConnected && true === response.valid ) { + savePost(); + } + setOauthUrl( response.auth_url ); + setIsConnected( response.valid ); + } ); + } }; + useEffect( () => { + // Fetch provider and campaign data. + if ( isSupportedESP() ) { + fetchNewsletterData( postId ); + } + }, [] ); + useEffect( () => { if ( ! isConnected && hasOauth ) { verifyToken(); @@ -68,8 +87,23 @@ function NewsletterEdit( { apiFetchWithErrorHandling, setInFlightForAsync } ) { } }, [ serviceProviderName ] ); - const isDisplayingInitModal = shouldDisplaySettings || -1 === layoutId; + // Handle error messages from retrieve/sync requests with connected ESP. + useEffect( () => { + if ( newsletterDataError ) { + createNotice( 'error', newsletterDataError?.message || __( 'Error communicating with service provider.', 'newspack-newseltters' ), { + id: 'newspack-newsletters-newsletter-data-error', + isDismissible: true, + } ); + } else { + removeNotice( 'newspack-newsletters-newsletter-data-error' ); + } + }, newsletterDataError ); + if ( ! isSupportedESP() ) { + return null; + } + + const isDisplayingInitModal = shouldDisplaySettings || -1 === layoutId; const stylingId = 'newspack-newsletters-styling'; const stylingTitle = __( 'Newsletter Styles', 'newspack-newsletters' ); @@ -87,14 +121,23 @@ function NewsletterEdit( { apiFetchWithErrorHandling, setInFlightForAsync } ) { { stylingTitle } + + { isConnected && } + + - - { isConnected && } + + - { 'manual' !== serviceProviderName && ( + { isSupportedESP() && ( { - const { editPost } = dispatch( 'core/editor' ); + const { editPost, savePost } = dispatch( 'core/editor' ); const { saveEntityRecord } = dispatch( 'core' ); return { editPost, + savePost, saveLayout: payload => saveEntityRecord( 'postType', LAYOUT_CPT_SLUG, { status: 'publish', @@ -58,7 +80,16 @@ export default compose( [ }; } ), ] )( - ( { editPost, layoutId, saveLayout, postBlocks, postTitle, isEditedPostEmpty, layoutMeta } ) => { + ( { + editPost, + savePost, + layoutId, + saveLayout, + postBlocks, + postTitle, + isEditedPostEmpty, + layoutMeta + } ) => { const [ warningModalVisible, setWarningModalVisible ] = useState( false ); const { layouts, isFetchingLayouts } = useLayoutsState(); @@ -90,6 +121,8 @@ export default compose( [ post_title: updatedLayout.title.raw, post_type: LAYOUT_CPT_SLUG, } ); + + savePost(); }; const postContent = useMemo( () => serialize( postBlocks ), [ postBlocks ] ); diff --git a/src/newsletter-editor/public/index.js b/src/newsletter-editor/public/index.js index c1bffbf14..85bd0f0c3 100644 --- a/src/newsletter-editor/public/index.js +++ b/src/newsletter-editor/public/index.js @@ -1,7 +1,7 @@ /** * WordPress dependencies */ -import { __, sprintf } from '@wordpress/i18n'; +import { __ } from '@wordpress/i18n'; import { ToggleControl } from '@wordpress/components'; import { compose } from '@wordpress/compose'; import { withDispatch, withSelect } from '@wordpress/data'; @@ -13,16 +13,13 @@ const PublicSettingsComponent = props => { return ( +
updateIsPublic( value ) } diff --git a/src/newsletter-editor/sidebar/autocomplete.js b/src/newsletter-editor/sidebar/autocomplete.js new file mode 100644 index 000000000..d86748b46 --- /dev/null +++ b/src/newsletter-editor/sidebar/autocomplete.js @@ -0,0 +1,131 @@ +/** + * WordPress dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import { BaseControl, FormTokenField, Button, ButtonGroup } from '@wordpress/components'; +import { useState } from '@wordpress/element'; +import { Icon, external } from '@wordpress/icons'; + +/** + * Internal dependencies + */ +import { useIsRetrieving, useNewsletterDataError } from '../store'; + +// The autocomplete field for send lists and sublists. +const Autocomplete = ( { + availableItems, + label = '', + onChange, + onFocus, + onInputChange, + reset, + selectedInfo, +} ) => { + const [ isEditing, setIsEditing ] = useState( false ); + const isRetrieving = useIsRetrieving(); + const error = useNewsletterDataError(); + + if ( selectedInfo && ! isEditing ) { + return ( +
+

+ { selectedInfo.name } + + { selectedInfo.entity_type.charAt(0).toUpperCase() + selectedInfo.entity_type.slice(1) } + { selectedInfo?.hasOwnProperty( 'count' ) + ? ' • ' + + sprintf( + // Translators: If available, show a contact count alongside the selected item's type. %d is the number of contacts in the item. + _n( '%d contact', '%d contacts', selectedInfo.count, 'newspack-newsletters' ), + selectedInfo.count.toLocaleString() + ) + : '' } + +

+ + + + { selectedInfo?.edit_link && ( + + ) } + +
+ ); + } + + return ( +
+ + { + onChange( selectedLabels ); + setIsEditing( false ); + } } + onFocus={ onFocus } + onInputChange={ onInputChange } + suggestions={ availableItems.map( item => item.label ) } + placeholder={ __( 'Start typing to search by name or type', 'newspack-newsletters' ) } + value={ [] } + __experimentalExpandOnFocus={ true } + __experimentalShowHowTo={ false } + /> + { error && ( +

+ { error?.message || __( 'Error fetching send lists.', 'newspack-newsletters' ) } +

+ ) } +
+ { selectedInfo && ( + + + + ) } +
+ ); +}; + +export default Autocomplete; diff --git a/src/newsletter-editor/sidebar/index.js b/src/newsletter-editor/sidebar/index.js index 7f95fec06..191bbda75 100644 --- a/src/newsletter-editor/sidebar/index.js +++ b/src/newsletter-editor/sidebar/index.js @@ -4,21 +4,23 @@ import { __ } from '@wordpress/i18n'; import { compose } from '@wordpress/compose'; import { withSelect, withDispatch } from '@wordpress/data'; -import { Fragment } from '@wordpress/element'; -import { Button, TextControl, TextareaControl } from '@wordpress/components'; +import { useEffect } from '@wordpress/element'; +import { Button, Notice, Spinner, TextControl, TextareaControl } from '@wordpress/components'; /** * External dependencies */ -import classnames from 'classnames'; import { once } from 'lodash'; /** * Internal dependencies */ -import { getEditPostPayload, hasValidEmail } from '../utils'; +import Sender from './sender'; +import SendTo from './send-to'; import { getServiceProvider } from '../../service-providers'; import withApiHandler from '../../components/with-api-handler'; +import { fetchNewsletterData, useIsRetrieving, useNewsletterData, useNewsletterDataError } from '../store'; +import { isSupportedESP } from '../utils'; import './style.scss'; const Sidebar = ( { @@ -29,22 +31,60 @@ const Sidebar = ( { errors, editPost, title, - senderName, + meta, senderEmail, + senderName, + status, campaignName, previewText, - newsletterData, - stringifiedLayoutDefaults, - apiFetchWithErrorHandling, + stringifiedCampaignDefaults, postId, } ) => { - const apiFetch = config => - apiFetchWithErrorHandling( config ).then( result => { - if ( typeof result === 'object' && result.campaign ) { - editPost( getEditPostPayload( result ) ); - } - return result; - } ); + const isRetrieving = useIsRetrieving(); + const newsletterData = useNewsletterData(); + const newsletterDataError = useNewsletterDataError(); + const campaign = newsletterData?.campaign; + const updateMeta = ( toUpdate ) => editPost( { meta: toUpdate } ); + + // Reconcile stored campaign data with data fetched from ESP. + useEffect( () => { + const updatedMeta = {}; + if ( newsletterData?.senderEmail ) { + updatedMeta.senderEmail = newsletterData.senderEmail; + } + if ( newsletterData?.senderName ) { + updatedMeta.senderName = newsletterData.senderName; + } + if ( newsletterData?.send_list_id ) { + updatedMeta.send_list_id = newsletterData.send_list_id; + } + if ( newsletterData?.send_sublist_id ) { + updatedMeta.send_sublist_id = newsletterData.send_sublist_id; + } + if ( Object.keys( updatedMeta ).length ) { + updateMeta( updatedMeta ); + } + }, [ newsletterData ] ); + + useEffect( () => { + const campaignDefaults = 'string' === typeof stringifiedCampaignDefaults ? JSON.parse( stringifiedCampaignDefaults ) : stringifiedCampaignDefaults; + const updatedMeta = {}; + if ( campaignDefaults?.senderEmail ) { + updatedMeta.senderEmail = campaignDefaults.senderEmail; + } + if ( campaignDefaults?.senderName ) { + updatedMeta.senderName = campaignDefaults.senderName; + } + if ( campaignDefaults?.send_list_id ) { + updatedMeta.send_list_id = campaignDefaults.send_list_id; + } + if ( campaignDefaults?.send_sublist_id ) { + updatedMeta.send_sublist_id = campaignDefaults.send_sublist_id; + } + if ( Object.keys( updatedMeta ).length ) { + updateMeta( updatedMeta ); + } + }, [ stringifiedCampaignDefaults ] ); const getCampaignName = () => { if ( typeof campaignName === 'string' ) { @@ -53,79 +93,9 @@ const Sidebar = ( { return 'Newspack Newsletter (' + postId + ')'; }; - const renderCampaignName = () => ( - editPost( { meta: { campaign_name: value } } ) } - /> - ); - - const renderSubject = () => ( - editPost( { title: value } ) } - /> - ); - - const senderEmailClasses = classnames( - 'newspack-newsletters__email-textcontrol', - errors.newspack_newsletters_unverified_sender_domain && 'newspack-newsletters__error' - ); - - const renderFrom = ( { handleSenderUpdate } ) => ( - - - { __( 'From', 'newspack-newsletters' ) } - - { - editPost( { meta: { senderName: value } } ); - } } - /> - { - editPost( { meta: { senderEmail: value } } ); - } } - /> - - - ); - - const renderPreviewText = () => ( - editPost( { meta: { preview_text: value } } ) } - /> - ); - if ( false === isConnected ) { return ( - + <>

{ __( 'You must authorize your account before publishing your newsletter.', @@ -133,7 +103,7 @@ const Sidebar = ( { ) }

-
+ + ); + } + + if ( ! campaign && newsletterDataError?.message ) { + return ( +
+ + { __( 'There was an error retrieving campaign data. Please try again.', 'newspack-newsletters' ) } + + +
+ ); + } + + if ( ! campaign && ! newsletterDataError?.message ) { + return ( +
+ { __( 'Retrieving campaign data…', 'newspack-newsletters' ) } + +
+ ); + } + + const { ProviderSidebar = () => null, isCampaignSent } = getServiceProvider(); + const campaignIsSent = ! inFlight && newsletterData && isCampaignSent && isCampaignSent( newsletterData, status ); + + if ( campaignIsSent ) { + return ( + + { __( 'Campaign has been sent.', 'newspack-newsletters' ) } + ); } - // eslint-disable-next-line @wordpress/no-unused-vars-before-return - const { ProviderSidebar } = getServiceProvider(); return ( - +
+ updateMeta( { campaign_name: value } ) } + /> + editPost( { title: value } ) } + /> + updateMeta( { preview_text: value } ) } + /> editPost( { meta } ) } + postId={ postId } + meta={ meta } + updateMeta={ updateMeta } + /> +
+ - + { + isSupportedESP() && ( + + ) + } +
); }; export default compose( [ withApiHandler(), withSelect( select => { - const { getEditedPostAttribute, getCurrentPostId } = select( 'core/editor' ); + const { getCurrentPostAttribute, getCurrentPostId, getEditedPostAttribute } = select( 'core/editor' ); const meta = getEditedPostAttribute( 'meta' ); return { title: getEditedPostAttribute( 'title' ), postId: getCurrentPostId(), - senderEmail: meta.senderEmail || '', - senderName: meta.senderName || '', + meta, + senderEmail: meta.senderEmail, + senderName: meta.senderName, campaignName: meta.campaign_name, previewText: meta.preview_text || '', - newsletterData: meta.newsletterData || {}, - stringifiedLayoutDefaults: meta.stringifiedLayoutDefaults || {}, + status: getCurrentPostAttribute( 'status' ), + stringifiedCampaignDefaults: meta.stringifiedCampaignDefaults || {}, }; } ), withDispatch( dispatch => { const { editPost } = dispatch( 'core/editor' ); - return { editPost }; + const { createErrorNotice } = dispatch( 'core/notices' ); + return { editPost, createErrorNotice }; } ), ] )( Sidebar ); diff --git a/src/newsletter-editor/sidebar/send-to.js b/src/newsletter-editor/sidebar/send-to.js new file mode 100644 index 000000000..e24a92836 --- /dev/null +++ b/src/newsletter-editor/sidebar/send-to.js @@ -0,0 +1,204 @@ +/* global newspack_newsletters_data */ + +/** + * WordPress dependencies + */ +import { __, _n, sprintf } from '@wordpress/i18n'; +import { Notice } from '@wordpress/components'; +import { useDispatch, useSelect } from '@wordpress/data'; +import { useEffect, useState } from '@wordpress/element'; + +/** + * Internal dependencies + */ +import Autocomplete from './autocomplete'; +import { fetchSendLists, useNewsletterData } from '../store'; + +// The container for list + sublist autocomplete fields. +const SendTo = () => { + const [ error, setError ] = useState( null ); + const { listId, sublistId } = useSelect( select => { + const { getEditedPostAttribute } = select( 'core/editor' ); + const meta = getEditedPostAttribute( 'meta' ); + return { + listId: meta.send_list_id, + sublistId: meta.send_sublist_id, + }; + } ); + + const editPost = useDispatch( 'core/editor' ).editPost; + const updateMeta = ( meta ) => editPost( { meta } ); + + const newsletterData = useNewsletterData(); + const { lists = [], sublists } = newsletterData; // All ESPs have lists, but not all have sublists. + const { labels } = newspack_newsletters_data || {}; + const listLabel = labels?.list || __( 'list', 'newspack-newsletters' ); + const sublistLabel = labels?.sublist || __( 'sublist', 'newspack-newsletters' ); + const selectedList = lists.find( item => item.id === listId ); + const selectedSublist = sublists?.find( item => item.id === sublistId ); + + // Cancel any queued fetches on unmount. + useEffect( () => { + return () => { + fetchSendLists.cancel(); + } + }, [] ); + + useEffect( () => { + // If we have a selected list ID but no list info, fetch it. + if ( listId && ! selectedList ) { + fetchSendLists( { ids: [ listId ] } ); + } + + // If we have a selected sublist ID but no sublist info, fetch it. + if ( listId && sublistId && ! selectedSublist ) { + fetchSendLists( { ids: [ sublistId ], type: 'sublist', parent_id: listId } ); + } + + // Prefetch sublist info when selecting a new list ID. + if ( listId && ! sublistId && newsletterData?.sublists && 1 >= newsletterData.sublists.length ) { + fetchSendLists( { type: 'sublist', parent_id: listId } ); + } + }, [ newsletterData, listId, sublistId ] ); + + const renderSelectedSummary = () => { + if ( ! selectedList?.name || ( selectedSublist && ! selectedSublist.name ) ) { + return null; + } + let summary; + if ( selectedList.list && ! selectedSublist?.name ) { + summary = sprintf( + // Translators: A summary of which list the campaign is set to send to, and the total number of contacts, if available. %1$s is the number of contacts. %2$s is the label of the list (ex: Main), %3$s is the label for the type of the list (ex: "list" on Active Campaign and "audience" on Mailchimp). + _n( + 'This newsletter will be sent to %1$s contact in the %2$s %3$s.', + 'This newsletter will be sent to all %1$s contacts in the %2$s %3$s.', + selectedList?.count || 0, + 'newspack-newsletters' + ), + selectedList?.count ? selectedList.count.toLocaleString() : '', + selectedList?.name, + selectedList?.entity_type?.toLowerCase() + ); + } + if ( selectedList && selectedSublist?.name ) { + summary = sprintf( + // Translators: A summary of which list the campaign is set to send to, and the total number of contacts, if available. %1$s is the number of contacts. %2$s is the label of the list (ex: Main), %3$s is the label for the type of the list (ex: "list" on Active Campaign and "audience" on Mailchimp). + _n( + 'This newsletter will be sent to %1$s contact in the %2$s %3$s who is part of the %4$s %5$s.', + 'This newsletter will be sent to all %1$s contacts in the %2$s %3$s who are part of the %4$s %5$s.', + selectedSublist?.count || 0, + 'newspack-newsletters' + ), + selectedSublist.count ? selectedSublist.count.toLocaleString() : '', + selectedList?.name, + selectedList?.entity_type?.toLowerCase(), + selectedSublist.name, + selectedSublist.entity_type?.toLowerCase() + ); + } + + return ( +

+ ); + }; + + return ( + <> +


+ + { __( 'Send to', 'newspack-newsletters' ) } + + { error && ( + + { error } + + ) } + { + ( newsletterData?.fetched_list || newsletterData?.fetched_sublist ) && ( + + { __( 'Updated send-to info fetched from ESP.', 'newspack-newsletters' ) } + + ) + } + { + const selectedLabel = selectedLabels[ 0 ]; + const selectedSuggestion = lists.find( item => item.label === selectedLabel ); + if ( ! selectedSuggestion?.id ) { + return setError( + sprintf( + // Translators: Error shown when we can't find info on the selected list. %s is the ESP's label for the list entity. + __( 'Invalid %s selection.', 'newspack-newsletters' ), + listLabel + ) + ); + } + updateMeta( { send_list_id: selectedSuggestion.id.toString(), send_sublist_id: null } ); + } } + onFocus={ () => { + if ( 1 >= lists?.length ) { + fetchSendLists(); + } + } } + onInputChange={ search => search && fetchSendLists( { search } ) } + reset={ () => { + updateMeta( { send_list_id: null, send_sublist_id: null } ) + } } + selectedInfo={ selectedList } + setError={ setError } + updateMeta={ updateMeta } + /> + { + sublists && listId && ( + ! item.parent || listId === item.parent ) } + label={ sublistLabel } + parentId={ listId } + onChange={ selectedLabels => { + const selectedLabel = selectedLabels[ 0 ]; + const selectedSuggestion = sublists.find( item => item.label === selectedLabel && ( ! item.parent || listId === item.parent ) ); + if ( ! selectedSuggestion?.id ) { + return setError( + sprintf( + // Translators: Error shown when we can't find info on the selected sublist. %s is the ESP's label for the sublist entity or entities. + __( 'Invalid %s selection.', 'newspack-newsletters' ), + sublistLabel + ) + ); + } + updateMeta( { send_sublist_id: selectedSuggestion.id.toString() } ); + } } + onFocus={ () => { + if ( 1 >= sublists?.length ) { + fetchSendLists( { + type: 'sublist', + parent_id: listId + } ); + } + } } + onInputChange={ search => search && fetchSendLists( { + search, + type: 'sublist', + parent_id: listId + } ) } + reset={ () => { + updateMeta( { send_list_id: listId, send_sublist_id: null } ) + } } + selectedInfo={ selectedSublist } + setError={ setError } + updateMeta={ updateMeta } + /> + ) + } + { renderSelectedSummary()} + + ); +}; + +export default SendTo; diff --git a/src/newsletter-editor/sidebar/sender.js b/src/newsletter-editor/sidebar/sender.js new file mode 100644 index 000000000..ed7fc7e33 --- /dev/null +++ b/src/newsletter-editor/sidebar/sender.js @@ -0,0 +1,122 @@ +/** + * WordPress dependencies + */ +import { __, sprintf } from '@wordpress/i18n'; +import { Button, Notice, SelectControl, TextControl } from '@wordpress/components'; +import { Icon, external } from '@wordpress/icons'; + +/** + * External dependencies + */ +import classnames from 'classnames'; + +/** + * Internal dependencies + */ +import { useNewsletterData } from '../../newsletter-editor/store'; +import { hasValidEmail } from '../utils'; + +const Sender = ( + { + errors, + inFlight, + senderEmail, + senderName, + updateMeta + } +) => { + const newsletterData = useNewsletterData(); + const { allowed_sender_domains: allowedDomains, allowed_sender_emails: allowedEmails = null, email_settings_url: settingsUrl } = newsletterData; + const senderEmailClasses = classnames( + 'newspack-newsletters__email-textcontrol', + errors.newspack_newsletters_unverified_sender_domain && 'newspack-newsletters__error' + ); + const validDomainsMessage = allowedDomains?.length && allowedDomains.every( domain => ! senderEmail || ! senderEmail.includes( domain ) ) ? + sprintf( + /* translators: %s: list of allowed domains */ + __( 'Sender email must contain one of the following domains: %s', 'newspack-newsletters' ), + allowedDomains.join( ', ' ) + ) : + ''; + + return ( + <> + + { __( 'Sender', 'newspack-newsletters' ) } + + { + ( newsletterData?.senderEmail || newsletterData?.senderName ) && ( + + { __( 'Updated sender info fetched from ESP.', 'newspack-newsletters' ) } + + ) + } + updateMeta( { senderName: value } ) } + placeholder={ __( 'The campaign’s sender name.', 'newspack-newsletters' ) } + /> + { ! inFlight && null === allowedEmails && ( + updateMeta( { senderEmail: value } ) } + placeholder={ __( 'The campaign’s sender email.', 'newspack-newsletters' ) } + /> + ) } + { Array.isArray( allowedEmails ) && ( + <> + { ! inFlight && ! allowedEmails.length && ( + + { __( 'There are no verified email addresses.', 'newspack-newsletters' ) } + + ) } + { allowedEmails.length && ( + updateMeta( { senderEmail: value } ) } + options={ [ + { + label: __( '-- Select a sender email --', 'newspack-newsletters' ), + value: '' + }, + ].concat( + allowedEmails.map( email => ( { + label: email, + value: email, + } ) ) + ) } + /> + ) } + { settingsUrl && ( + + ) } + + ) } + + ); +} + +export default Sender; \ No newline at end of file diff --git a/src/newsletter-editor/sidebar/style.scss b/src/newsletter-editor/sidebar/style.scss index 4dd680179..b4ad508c7 100644 --- a/src/newsletter-editor/sidebar/style.scss +++ b/src/newsletter-editor/sidebar/style.scss @@ -27,6 +27,27 @@ } } + &__sidebar { + .components-notice + * { + margin-top: 16px; + } + } + + &__send-to { + margin: 16px 0; + .components-button-group { + margin-top: 8px; + } + .newspack-newsletters__send-to-details { + font-weight: 500; + margin-bottom: 0; + span { + color: wp-colors.$gray-600; + display: block; + } + } + } + &__send-test { .components-button + .components-button { margin-left: 12px; @@ -41,6 +62,10 @@ } } + &__error { + color: wp-colors.$alert-red; + } + &__error input.components-text-control__input { border-color: wp-colors.$alert-red; } diff --git a/src/newsletter-editor/store.js b/src/newsletter-editor/store.js new file mode 100644 index 000000000..1e3c66ed5 --- /dev/null +++ b/src/newsletter-editor/store.js @@ -0,0 +1,224 @@ +/** + * A Redux store for ESP newsletter data to be used across editor components. + * This store is a centralized place for all data fetched from or updated via the ESP's API. + * + * Import use* hooks to read store data from any component. + * Import fetch* hooks to fetch updated ESP data from any component. + * Import update* hooks to update store data from any component. + */ + +/** + * WordPress dependencies. + */ +import apiFetch from '@wordpress/api-fetch'; +import { createReduxStore, dispatch, register, useSelect, select as coreSelect } from '@wordpress/data'; +import { addQueryArgs } from '@wordpress/url'; + +/** + * Internal dependencies + */ +import { getServiceProvider } from '../service-providers'; + +/** + * External dependencies + */ +import { debounce, sortBy } from 'lodash'; + +export const STORE_NAMESPACE = 'newspack/newsletters'; + +const DEFAULT_STATE = { + isRetrieving: false, + newsletterData: {}, + error: null, +}; +const createAction = type => payload => ( { type, payload } ); +const reducer = ( state = DEFAULT_STATE, { type, payload = {} } ) => { + switch ( type ) { + case 'SET_IS_RETRIEVING': + return { ...state, isRetrieving: payload }; + case 'SET_DATA': + const updatedNewsletterData = { ...state.newsletterData, ...payload }; + return { ...state, newsletterData: updatedNewsletterData }; + case 'SET_ERROR': + return { ...state, error: payload }; + default: + return state; + } +}; + +const actions = { + // Regular actions. + setIsRetrieving: createAction( 'SET_IS_RETRIEVING' ), + setData: createAction( 'SET_DATA' ), + setError: createAction( 'SET_ERROR' ), +}; + +const selectors = { + getIsRetrieving: state => state.isRetrieving, + getData: state => state.newsletterData || {}, + getError: state => state.error, +}; + +const store = createReduxStore( STORE_NAMESPACE, { + reducer, + actions, + selectors, +} ); + +// Register the editor store. +export const registerStore = () => register( store ); + +// Hook to use the retrieval status from any editor component. +export const useIsRetrieving = () => + useSelect( select => + select( STORE_NAMESPACE ).getIsRetrieving() + ); + +// Hook to use the newsletter data from any editor component. +export const useNewsletterData = () => + useSelect( select => + select( STORE_NAMESPACE ).getData() + ); + +// Hook to use newsletter data fetch errors from any editor component. +export const useNewsletterDataError = () => + useSelect( select => + select( STORE_NAMESPACE ).getError() + ); + +// Dispatcher to update retrieval status in the store. +export const updateIsRetrieving = isRetrieving => + dispatch( STORE_NAMESPACE ).setIsRetrieving( isRetrieving ); + +// Dispatcher to update newsletter data in the store. +export const updateNewsletterData = data => + dispatch( STORE_NAMESPACE ).setData( data ); + +// Dispatcher to update newsletter error in the store. +export const updateNewsletterDataError = error => + dispatch( STORE_NAMESPACE ).setError( error ); + +// Dispatcher to fetch newsletter data from the server. +export const fetchNewsletterData = async postId => { + const isRetrieving = coreSelect( STORE_NAMESPACE ).getIsRetrieving(); + if ( isRetrieving ) { + return; + } + updateIsRetrieving( true ); + updateNewsletterDataError( null ); + try { + const { name } = getServiceProvider(); + const response = await apiFetch( { + path: `/newspack-newsletters/v1/${ name }/${ postId }/retrieve`, + } ); + + // If we've already fetched list or sublist info, retain it. + const newsletterData = coreSelect( STORE_NAMESPACE ).getData(); + const updatedNewsletterData = { ...response }; + if ( newsletterData?.lists ) { + updatedNewsletterData.lists = newsletterData.lists; + } + if ( newsletterData?.sublists ) { + updatedNewsletterData.sublists = newsletterData.sublists; + } + updateNewsletterData( updatedNewsletterData ); + } catch ( error ) { + updateNewsletterDataError( error ); + } + updateIsRetrieving( false ); +}; + +// Dispatcher to fetch any errors from the most recent sync attempt. +export const fetchSyncErrors = async postId => { + const isRetrieving = coreSelect( STORE_NAMESPACE ).getIsRetrieving(); + if ( isRetrieving ) { + return; + } + updateIsRetrieving( true ); + updateNewsletterDataError( null ); + try { + const response = await apiFetch( { + path: `/newspack-newsletters/v1/${ postId }/sync-error`, + } ); + if ( response?.message ) { + updateNewsletterDataError( response ); + } + } catch ( error ) { + updateNewsletterDataError( error ); + } + updateIsRetrieving( false ); +} + +// Dispatcher to fetch send lists and sublists from the connected ESP and update the newsletterData in store. +export const fetchSendLists = debounce( async ( opts ) => { + updateNewsletterDataError( null ); + try { + const { name } = getServiceProvider(); + const args = { + type: 'list', + limit: 10, + provider: name, + ...opts, + }; + + const newsletterData = coreSelect( STORE_NAMESPACE ).getData(); + const sendLists = 'list' === args.type ? [ ...newsletterData?.lists ] || [] : [ ...newsletterData?.sublists ] || []; + + // If we already have a matching result, no need to fetch more. + const foundItems = sendLists.filter( item => { + const ids = args.ids && ! Array.isArray( args.ids ) ? [ args.ids ] : args.ids; + const search = args.search && ! Array.isArray( args.search ) ? [ args.search ] : args.search; + let found = false; + if ( ids?.length ) { + ids.forEach( id => { + found = item.id.toString() === id.toString(); + } ) + } + if ( search?.length ) { + search.forEach( term => { + if ( item.label.toLowerCase().includes( term.toLowerCase() ) ) { + found = true; + } + } ); + } + + return found; + } ); + + if ( foundItems.length ) { + return sendLists; + } + + const updatedNewsletterData = { ...newsletterData }; + const updatedSendLists = [ ...sendLists ]; + + // If no existing items found, fetch from the ESP. + const isRetrieving = coreSelect( STORE_NAMESPACE ).getIsRetrieving(); + if ( isRetrieving ) { + return; + } + updateIsRetrieving( true ); + const response = await apiFetch( { + path: addQueryArgs( + '/newspack-newsletters/v1/send-lists', + args + ) + } ); + + response.forEach( item => { + if ( ! updatedSendLists.find( listItem => listItem.id === item.id ) ) { + updatedSendLists.push( item ); + } + } ); + if ( 'list' === args.type ) { + updatedNewsletterData.lists = sortBy( updatedSendLists, 'label' ); + } else { + updatedNewsletterData.sublists = sortBy( updatedSendLists, 'label' ); + } + + updateNewsletterData( updatedNewsletterData ); + } catch ( error ) { + updateNewsletterDataError( error ); + } + updateIsRetrieving( false ); +}, 500 ); \ No newline at end of file diff --git a/src/newsletter-editor/testing/index.js b/src/newsletter-editor/testing/index.js index 79bfd7847..b287f0548 100644 --- a/src/newsletter-editor/testing/index.js +++ b/src/newsletter-editor/testing/index.js @@ -13,6 +13,7 @@ import { hasValidEmail } from '../utils'; * Internal dependencies */ import withApiHandler from '../../components/with-api-handler'; +import { useNewsletterData } from '../store'; import './style.scss'; const serviceProvider = @@ -44,6 +45,7 @@ export default compose( [ } ) => { const [ localInFlight, setLocalInFlight ] = useState( false ); const [ localMessage, setLocalMessage ] = useState( '' ); + const { supports_multiple_test_recipients: supportsMultipleTestEmailRecipients } = useNewsletterData(); const sendTestEmail = async () => { if ( inlineNotifications ) { setLocalInFlight( true ); @@ -78,20 +80,18 @@ export default compose( [ } }; - const supportsMultipleTestEmailRecipients = serviceProvider !== 'active_campaign'; return (
-