From 2df6fef6f7948ecf3c9bc2abd60f8eeb722a44d5 Mon Sep 17 00:00:00 2001 From: dkoo Date: Thu, 29 Aug 2024 10:21:52 -0600 Subject: [PATCH 1/7] feat: cross-ESP send-lists --- includes/class-send-list.php | 213 +++++++++++++++++++++++++++++++ includes/class-send-lists.php | 175 +++++++++++++++++++++++++ tests/bootstrap.php | 3 + tests/test-send-list.php | 76 +++++++++++ tests/test-send-lists.php | 37 ++++++ tests/trait-send-lists-setup.php | 53 ++++++++ 6 files changed, 557 insertions(+) create mode 100644 includes/class-send-list.php create mode 100644 includes/class-send-lists.php create mode 100644 tests/test-send-list.php create mode 100644 tests/test-send-lists.php create mode 100644 tests/trait-send-lists-setup.php diff --git a/includes/class-send-list.php b/includes/class-send-list.php new file mode 100644 index 000000000..119a48365 --- /dev/null +++ b/includes/class-send-list.php @@ -0,0 +1,213 @@ + $property ) { + // If the property is required but not set, throw an error. + if ( ! empty( $property['required'] ) && ! isset( $config[ $key ] ) ) { + $errors[] = __( 'Missing required config: ', 'newspack-newsletters' ) . $key; + continue; + } + + // No need to continue if an optional key isn't set. + if ( ! isset( $config[ $key ] ) ) { + continue; + } + + // If the passed value isn't in the enum, throw an error. + if ( isset( $property['enum'] ) && isset( $config[ $key ] ) && ! in_array( $config[ $key ], $property['enum'], true ) ) { + $errors[] = __( 'Invalid value for config: ', 'newspack-newsletters' ) . $key; + continue; + } + + // Cast value to the expected type. + settype( $config[ $key ], $property['type'] ); + + // Set the property. + $this->set( $key, $config[ $key ] ); + } + + if ( ! empty( $errors ) ) { + throw new \InvalidArgumentException( esc_html( __( 'Error creating send list: ', 'newspack-newsletters' ) . implode( ' | ', $errors ) ) ); + } + + $this->set_label_and_value(); + } + + /** + * Get the config data schema for a single Send_List. + */ + public static function get_config_schema() { + return [ + 'type' => 'object', + 'additionalProperties' => false, + 'properties' => [ + // The slug of the ESP for which this list or sublist originates. + 'provider' => [ + 'name' => 'provider', + 'type' => 'string', + 'required' => true, + 'enum' => Newspack_Newsletters::get_supported_providers(), + ], + // The type of list. Can be either 'list' or 'sublist'. If the latter, must specify a `parent` property. + 'type' => [ + 'name' => 'type', + 'type' => 'string', + 'required' => true, + 'enum' => [ + 'list', + 'sublist', + ], + ], + // The type of entity this list or sublist is associated with in the ESP. Controls which ESP API endpoints to use to fetch/update. + 'entity_type' => [ + 'name' => 'entity_type', + 'type' => 'string', + 'required' => true, + ], + // The ID of the list or sublist as identified in the ESP. + 'id' => [ + 'name' => 'id', + 'type' => 'string', + 'required' => true, + ], + 'value' => [ + 'name' => 'value', + 'type' => 'string', + 'required' => false, + ], + // The name of the list or sublist as identified in the ESP. + 'name' => [ + 'name' => 'name', + 'type' => 'string', + 'required' => true, + ], + 'label' => [ + 'name' => 'label', + 'type' => 'string', + 'required' => false, + ], + // If the list is also a Subscription List, it could have a locally edited name. + 'local_name' => [ + 'name' => 'local_name', + 'type' => 'string', + 'required' => false, + ], + // If available, the number of contacts associated with this list or sublist. + 'count' => [ + 'name' => 'count', + 'type' => 'integer', + 'required' => false, + ], + // If this Send_List is a sublist, this property must indicate the ID of the parent list. + 'parent_id' => [ + 'name' => 'parent_id', + 'type' => 'string', + 'required' => false, + ], + // If it can be calculated, the URL to view this list or sublist in the ESP's dashboard. + 'edit_link' => [ + 'name' => 'edit_link', + 'type' => 'string', + 'required' => false, + ], + ], + ]; + } + + /** + * Get the Send_List configuration. + * + * @return array + */ + public function get_config() { + $schema = self::get_config_schema(); + $config = []; + foreach ( $schema['properties'] as $key => $property ) { + $config[ $key ] = $this->get( $key ) ?? null; + } + + return array_filter( $config ); + } + + /** + * Get a specific property's value. + * + * @param string $key The property to get. + * + * @return mixed The property value or null if not set/not a supported property. + */ + public function get( $key ) { + return $this->{ $key } ?? null; + } + + /** + * Set a property's value. + * + * @param string $key The property to get. + * @param mixed $value The value to set. + * + * @return mixed The property value or null if not set/not a supported property. + */ + public function set( $key, $value ) { + $schema = $this->get_config_schema(); + if ( ! isset( $schema['properties'][ $key ] ) ) { + return null; + } + $this->{ $key } = $value; + return $this->get( $key ); + } + + /** + * Set the label and value properties for autocomplete inputs. + */ + public function set_label_and_value() { + $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 ) + ) : ''; + + $this->set( 'value', $this->get( 'id' ) ); + $this->set( 'label', trim( "$entity_type $name $contact_count" ) ); + } +} diff --git a/includes/class-send-lists.php b/includes/class-send-lists.php new file mode 100644 index 000000000..7d4fa95c1 --- /dev/null +++ b/includes/class-send-lists.php @@ -0,0 +1,175 @@ + \WP_REST_Server::READABLE, + 'callback' => [ __CLASS__, 'api_get_send_lists' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_permission_callback' ], + '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 $id === $ids; + } + + /** + * Check if the given search term matches any of the given strings. + * + * @param array|string $search Search term or array of terms. + * @param array $matches An array of strings to match against. + * + * @return boolean + */ + public static function matches_search( $search, $matches = [] ) { + if ( empty( $search ) ) { + return true; + } + if ( ! is_array( $search ) ) { + $search = [ $search ]; + } + foreach ( $search as $to_match ) { + $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. + * + * @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 ) + ); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php index 317635b8f..297400dca 100755 --- a/tests/bootstrap.php +++ b/tests/bootstrap.php @@ -36,6 +36,9 @@ function _manually_load_plugin() { // Trait used to test Subscription Lists. require_once 'trait-lists-setup.php'; +// Trait used to test Send Lists. +require_once 'trait-send-lists-setup.php'; + // MailChimp mock. require_once 'mocks/class-mailchimp-mock.php'; diff --git a/tests/test-send-list.php b/tests/test-send-list.php new file mode 100644 index 000000000..bf41bbbc1 --- /dev/null +++ b/tests/test-send-list.php @@ -0,0 +1,76 @@ +assertInstanceOf( Send_List::class, $list ); + $this->assertInstanceOf( Send_List::class, $sublist ); + } + + /** + * Test constructor with invalid input. + */ + public function test_constructor_with_invalid() { + $this->expectException( \InvalidArgumentException::class ); + $list = new Send_List( self::$configs['invalid'] ); + } + + + /** + * Test get_config_schema. + */ + public function test_get_config_schema() { + $config = self::$configs['valid_list']; + $config['unsupported_prop'] = 'unsupported'; + + $list = new Send_List( $config ); + $list_config = $list->get_config(); + $schema = $list->get_config_schema(); + + // Unsupported props are ignored. + $this->assertArrayNotHasKey( 'unsupported_prop', $list_config ); + foreach ( $list_config as $key => $value ) { + $this->assertArrayHasKey( $key, $schema['properties'] ); + } + } + + /** + * Test get method. + */ + public function test_get() { + $config = self::$configs['valid_sublist']; + $sublist = new Send_List( $config ); + $this->assertSame( $config['provider'], $sublist->get( 'provider' ) ); + } + + /** + * Test type casting. + */ + public function test_type() { + $config = self::$configs['valid_list']; + $config['id'] = 123; // Integer. + $config['count'] = '100'; // String. + $list = new Send_List( $config ); + $schema = $list->get_config_schema(); + foreach ( $config as $key => $value ) { + $this->assertSame( gettype( $list->get( $key ) ), $schema['properties'][ $key ]['type'] ); + } + } +} diff --git a/tests/test-send-lists.php b/tests/test-send-lists.php new file mode 100644 index 000000000..c1ad365f7 --- /dev/null +++ b/tests/test-send-lists.php @@ -0,0 +1,37 @@ +assertTrue( Send_Lists::matches_id( '123', '123' ) ); // Single ID matches. + $this->assertTrue( Send_Lists::matches_id( [ '123', '456' ], '123' ) ); // Array of IDs matches. + $this->assertFalse( Send_Lists::matches_id( '456', '123' ) ); // Single of ID doesn't match. + $this->assertFalse( Send_Lists::matches_id( [ '456', '789' ], '123' ) ); // Array of IDs doesn't match. + } + + /** + * Test matching by search term(s). + */ + public function test_match_by_search() { + $this->assertTrue( Send_Lists::matches_search( 'search', [ 'search term', 'another term' ] ) ); // Single term matches. + $this->assertTrue( Send_Lists::matches_search( [ 'search', 'no match' ], [ 'search term', 'another term' ] ) ); // Single term matches. + $this->assertFalse( Send_Lists::matches_search( 'no match', [ 'search term', 'another term' ] ) ); // Single of ID doesn't match. + $this->assertFalse( Send_Lists::matches_search( [ 'no match', 'another no match' ], [ 'search term', 'another term' ] ) ); // Array of IDs doesn't match. + } +} diff --git a/tests/trait-send-lists-setup.php b/tests/trait-send-lists-setup.php new file mode 100644 index 000000000..3b9bd1688 --- /dev/null +++ b/tests/trait-send-lists-setup.php @@ -0,0 +1,53 @@ + [ + 'provider' => 'invalid_provider', + 'type' => 'invalid_type', + ], + 'valid_list' => [ + 'provider' => 'mailchimp', + 'type' => 'list', + 'id' => '123', + 'name' => 'Valid List', + 'entity_type' => 'Audience', + 'count' => 100, + ], + // Missing parent ID. + 'invalid_sublist' => [ + 'provider' => 'mailchimp', + 'type' => 'sublist', + 'id' => '456', + 'name' => 'Valid Sublist', + 'entity_type' => 'Group', + 'count' => 50, + ], + 'valid_sublist' => [ + 'provider' => 'mailchimp', + 'type' => 'sublist', + 'id' => '456', + 'parent' => '123', + 'name' => 'Valid Sublist', + 'entity_type' => 'Group', + 'count' => 50, + ], + ]; +} From 7c0d36a2c02da2edbe86eeada4580a04ff7e96b6 Mon Sep 17 00:00:00 2001 From: dkoo Date: Thu, 29 Aug 2024 10:29:51 -0600 Subject: [PATCH 2/7] refactor: move API namespace and permission callback to main class --- ...lass-newspack-newsletters-subscription.php | 34 ++++++------------- includes/class-newspack-newsletters.php | 10 ++++++ 2 files changed, 21 insertions(+), 23 deletions(-) diff --git a/includes/class-newspack-newsletters-subscription.php b/includes/class-newspack-newsletters-subscription.php index 928783632..2867e6271 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_permission_callback' ], ] ); 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_permission_callback' ], '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..78d3f83c2 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. @@ -834,6 +835,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. */ From 92b3348899d5871050297b6d7f71ea9df116fe24 Mon Sep 17 00:00:00 2001 From: dkoo Date: Thu, 29 Aug 2024 11:11:15 -0600 Subject: [PATCH 3/7] feat: support send lists in provider classes --- .../class-newspack-newsletters-editor.php | 2 + includes/class-newspack-newsletters.php | 70 +++ ...s-newspack-newsletters-active-campaign.php | 349 ++++++++--- ...ewsletters-campaign-monitor-controller.php | 127 ---- ...-newspack-newsletters-campaign-monitor.php | 262 +++++++-- ...ewsletters-service-provider-controller.php | 2 +- ...-newspack-newsletters-service-provider.php | 2 + ...ewsletters-constant-contact-controller.php | 149 ----- ...spack-newsletters-constant-contact-sdk.php | 52 +- ...-newspack-newsletters-constant-contact.php | 449 +++++++++++---- ...rface-newspack-newsletters-esp-service.php | 20 +- .../class-newspack-newsletters-letterhead.php | 9 + ...pack-newsletters-mailchimp-cached-data.php | 137 ++++- ...spack-newsletters-mailchimp-controller.php | 145 +---- .../class-newspack-newsletters-mailchimp.php | 545 +++++++++++++----- tests/test-labels.php | 4 +- 16 files changed, 1463 insertions(+), 861 deletions(-) diff --git a/includes/class-newspack-newsletters-editor.php b/includes/class-newspack-newsletters-editor.php index d3bbbb569..c98c7f4f5 100644 --- a/includes/class-newspack-newsletters-editor.php +++ b/includes/class-newspack-newsletters-editor.php @@ -319,6 +319,7 @@ 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 ( self::is_editing_email() ) { @@ -362,6 +363,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.php b/includes/class-newspack-newsletters.php index 78d3f83c2..afd621047 100644 --- a/includes/class-newspack-newsletters.php +++ b/includes/class-newspack-newsletters.php @@ -267,6 +267,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -285,6 +286,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', @@ -314,6 +379,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -329,6 +395,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -344,6 +411,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -359,6 +427,7 @@ public static function register_meta() { 'type' => 'string', 'single' => true, 'auth_callback' => '__return_true', + 'default' => '', ] ); \register_meta( @@ -374,6 +443,7 @@ public static function register_meta() { 'type' => 'boolean', 'single' => true, 'auth_callback' => '__return_true', + 'default' => false, ] ); \register_meta( 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 c00dca0a2..435098980 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. */ @@ -495,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; } @@ -509,43 +545,159 @@ 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. + * + * @return Send_List[]|WP_Error Array of Send_List objects on success, or WP_Error object on failure. + */ + public function get_send_lists( $args = [] ) { + $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, + ] + ); + } + } + + 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 ) ) { @@ -553,7 +705,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; } /** @@ -578,39 +736,63 @@ 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; + + $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 ); + $newsletter_data = [ + 'campaign' => true, // Satisfy the JS API. + 'campaign_id' => $campaign_id, + 'supports_multiple_test_recipients' => true, + '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', + ] + ), + 'sublists' => [], // Will be populated later if needed. + ]; + + // 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; + } } - $segments = $this->get_segments(); - if ( is_wp_error( $segments ) ) { - return $segments; + 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; + } } - $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, - ]; + + // 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; + } + } + 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; + } + } + if ( ! $campaign_id && true !== $skip_sync ) { $sync_result = $this->sync( get_post( $post_id ) ); if ( ! is_wp_error( $sync_result ) ) { - $result = wp_parse_args( + $newsletter_data = wp_parse_args( $sync_result, - $result + $newsletter_data ); } } - return $result; + return $newsletter_data; } /** @@ -714,17 +896,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 ] ] ); @@ -736,10 +920,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. @@ -749,7 +931,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' ) @@ -759,13 +941,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 ); @@ -776,14 +958,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 ); @@ -792,7 +967,6 @@ public function sync( $post ) { return $data; } else { $data = array_merge( $data, $sync_data ); - update_post_meta( $post->ID, 'newsletterData', $data ); } return $sync_data; @@ -811,8 +985,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 ); } @@ -821,10 +1000,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 ) { @@ -1335,6 +1514,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..decfa2e81 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. */ @@ -143,15 +46,6 @@ 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', @@ -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..452ccaba5 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,169 @@ 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. + * + * @return Send_List[]|WP_Error Array of Send_List objects on success, or WP_Error object on failure. + */ + public function get_send_lists( $args = [] ) { + $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' ) + ); + } + + $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 ); + }, + $this->get_lists() + ); + $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 ); + }, + $this->get_segments() + ); + $send_lists = array_merge( $send_lists, $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'] ); + } + + 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. */ - public function retrieve( $post_id, $fetch_all = false ) { + public function retrieve( $post_id ) { if ( ! $this->has_api_credentials() ) { return []; } 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; + $send_list_id = get_post_meta( $post_id, 'send_list_id', true ); + $newsletter_data = [ + 'campaign' => true, // Satisfy the JS API. + 'supports_multiple_test_recipients' => true, + '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', + ] + ), + ]; + + // 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 +428,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 +457,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 +468,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 +846,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 dd3ebd95d..4d76cf8e1 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. diff --git a/includes/service-providers/class-newspack-newsletters-service-provider.php b/includes/service-providers/class-newspack-newsletters-service-provider.php index 236687cd6..58991f842 100644 --- a/includes/service-providers/class-newspack-newsletters-service-provider.php +++ b/includes/service-providers/class-newspack-newsletters-service-provider.php @@ -448,8 +448,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' ), 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..7e697a111 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 @@ -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 a14e4b526..4c64b94b2 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 @@ -294,10 +294,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 ] ); } /** @@ -306,26 +308,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 86ecd266c..907c24179 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,7 +411,7 @@ 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; @@ -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; @@ -448,7 +478,7 @@ public function retrieve( $post_id ) { return []; } try { - $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 ); if ( ! $cc_campaign_id ) { @@ -458,21 +488,42 @@ public function retrieve( $post_id ) { $campaign = $cc->get_campaign( $cc_campaign_id ); } - $lists = $cc->get_contact_lists(); - $segments = $cc->get_segments(); - - $data = [ - 'lists' => $lists, - 'campaign' => $campaign, - 'campaign_id' => $cc_campaign_id, - 'segments' => $segments, + $list_id = $campaign->activity->contact_list_ids[0] ?? null; + $segment_id = $campaign->activity->segment_ids[0] ?? null; + $send_list_id = get_post_meta( $post_id, 'send_list_id', true ); + $newsletter_data = [ + 'campaign' => $campaign, + 'campaign_id' => $cc_campaign_id, + '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', ]; - // Store retrieved campaign data. - update_post_meta( $post_id, 'newsletterData', $data ); + // Reconcile campaign settings with info fetched from the ESP for a true two-way sync. + if ( ! empty( $campaign->activity->from_name ) && $campaign->activity->from_name !== get_post_meta( $post_id, 'senderName', true ) ) { + $newsletter_data['senderName'] = $campaign->activity->from_name; // If campaign has different sender info set, update ours. + } + if ( ! empty( $campaign->activity->from_email ) && $campaign->activity->from_email !== get_post_meta( $post_id, 'senderEmail', true ) ) { + $newsletter_data['senderEmail'] = $campaign->activity->from_email; // If campaign has different sender info set, update ours. + } + if ( ( $list_id || $segment_id ) && $list_id !== $send_list_id && $segment_id !== $send_list_id ) { + $newsletter_data['send_list_id'] = strval( $list_id ?? $segment_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. + $newsletter_data['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', + ] + ); - return $data; + return $newsletter_data; } catch ( Exception $e ) { + // If we couldn't get the campaign, delete the cc_campaign_id so it gets recreated on the next sync. + delete_post_meta( $post_id, 'cc_campaign_id' ); + $this->retrieve( $post_id ); + return new WP_Error( 'newspack_newsletters_constant_contact_error', $e->getMessage() @@ -498,7 +549,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 ); @@ -540,8 +591,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 ) ) { @@ -567,6 +617,86 @@ 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 object Payload for syncing. + */ + 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 ( ! 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. * @@ -598,21 +728,24 @@ 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, - ]; - - if ( $account_info->physical_address ) { - $activity_data['physical_address_in_footer'] = $account_info->physical_address; + $payload = $this->get_sync_payload( $post ); + + /** + * 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 ) { @@ -620,72 +753,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. - $this->retrieve( $post->ID ); - 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() ); @@ -748,7 +840,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( @@ -784,7 +876,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 ) { @@ -821,23 +913,136 @@ 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. + * + * @return Send_List[]|WP_Error Array of Send_List objects on success, or WP_Error object on failure. + */ + public function get_send_lists( $args = [] ) { + $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 ); + }, + $this->get_lists() + ); + $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 ); + }, + $this->get_segments() + ); + $send_lists = array_merge( $send_lists, $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'] ); + } + + return $filtered_lists; + } + /** * Add contact to a list or update an existing contact. * @@ -853,7 +1058,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 ]; @@ -895,7 +1100,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( @@ -939,7 +1144,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 */ @@ -986,6 +1191,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. ] ); } @@ -999,7 +1208,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 ); @@ -1018,7 +1227,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; @@ -1034,7 +1243,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; @@ -1056,7 +1265,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; @@ -1084,7 +1293,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 ] ); } @@ -1102,7 +1311,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..c0d1efc1d 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,15 @@ public function sync( $post ); */ public function get_lists(); + /** + * Get the ESP's available lists and sublists, reformatted as Send_List items. + * + * @param array $args Array of search args. See Send_Lists::get_default_args() for supported params and default values. + * + * @return array|WP_Error API Response or error. + */ + public function get_send_lists( $args ); + /** * 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..84904680d 100644 --- a/includes/service-providers/letterhead/class-newspack-newsletters-letterhead.php +++ b/includes/service-providers/letterhead/class-newspack-newsletters-letterhead.php @@ -70,6 +70,15 @@ 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. + * + * @return object|void|null API Response or Error + */ + public function get_send_lists( $args = [] ) {} // 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 be0cf324f..ce10f9f38 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 @@ -107,6 +107,35 @@ public static function add_cron_interval( $schedules ) { return $schedules; } + /** + * 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) * @@ -177,6 +206,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 * @@ -188,12 +224,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; } @@ -203,7 +239,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; } @@ -367,10 +403,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'; @@ -461,35 +502,83 @@ 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 = new Mailchimp( ( self::get_mc_instance() )->api_key() ); $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 = new Mailchimp( ( self::get_mc_instance() )->api_key() ); + $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 ) { $mc = new Mailchimp( ( self::get_mc_instance() )->api_key() ); $segments = []; @@ -498,7 +587,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 ), @@ -512,14 +601,16 @@ 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 ) { + public static function fetch_interest_categories( $list_id, $limit = null ) { $mc = new Mailchimp( ( self::get_mc_instance() )->api_key() ); $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; @@ -527,7 +618,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' ) ); } @@ -539,18 +630,20 @@ 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 = new Mailchimp( ( self::get_mc_instance() )->api_key() ); $tags = $list_id ? ( self::get_mc_instance() )->validate( $mc->get( "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..ea81de8fe 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,7 +63,7 @@ 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' ], @@ -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 21a64df6e..d8fc49ee4 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( @@ -411,34 +436,94 @@ public function retrieve( $post_id ) { } try { $mc_campaign_id = get_post_meta( $post_id, 'mc_campaign_id', true ); - 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; - $lists = $this->get_lists( true ); - if ( \is_wp_error( $lists ) ) { - return $lists; + // If there's no synced campaign ID yet, create it. + if ( ! $mc_campaign_id ) { + 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 ) ) { + return $sync_result; + } + $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 ); + $mc = new Mailchimp( $this->api_key() ); + $campaign = $this->validate( + $mc->get( + "campaigns/$mc_campaign_id", + [ + 'fields' => 'id,type,status,emails_sent,content_type,recipients,settings', + ] + ), + __( 'Error retrieving Mailchimp campaign.', 'newspack_newsletters' ) + ); } + $list_id = $campaign['recipients']['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 ) : [], ]; + // Reconcile campaign settings with info fetched from the ESP for a true two-way sync. + if ( ! empty( $campaign['settings']['from_name'] ) && $campaign['settings']['from_name'] !== get_post_meta( $post_id, 'senderName', true ) ) { + $newsletter_data['senderName'] = $campaign['settings']['from_name']; // If campaign has different sender info set, update ours. + } + if ( ! empty( $campaign['settings']['reply_to'] ) && $campaign['settings']['reply_to'] !== get_post_meta( $post_id, 'senderEmail', true ) ) { + $newsletter_data['senderEmail'] = $campaign['settings']['reply_to']; // 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['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 && $target_id !== $send_sublist_id ) { + $newsletter_data['sublist_id'] = $target_id; // If campaign has a different sublist selected, update ours. + $send_sublist_id = $target_id; + } + } + } + } + + // Prefetch send list info if we have a selected list and/or sublist. + $newsletter_data['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', + ] + ); + $newsletter_data['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', + ] + ) : + []; + return $newsletter_data; } catch ( Exception $e ) { + // If we couldn't get the campaign, delete the mc_campaign_id so it gets recreated on the next sync. + delete_post_meta( $post_id, 'mc_campaign_id' ); + $this->retrieve( $post_id ); + return new WP_Error( 'newspack_newsletters_mailchimp_error', $e->getMessage() @@ -455,92 +540,171 @@ 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. + * + * @return Send_List[]|WP_Error Array of Send_List objects on success, or WP_Error object on failure. + */ + public function get_send_lists( $args = [] ) { + $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() - ); } + + return $send_lists; } /** @@ -631,30 +795,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 +817,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 +845,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 +913,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. * @@ -789,15 +1039,8 @@ public function sync( $post ) { 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,6 +1053,11 @@ 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 ), @@ -838,13 +1086,6 @@ 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, @@ -1705,8 +1946,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/tests/test-labels.php b/tests/test-labels.php index 0a1fdc3a0..87ccb41ee 100644 --- a/tests/test-labels.php +++ b/tests/test-labels.php @@ -20,11 +20,11 @@ public function test_labels_inheritance() { $ac_labels = Newspack_Newsletters_Campaign_Monitor::get_labels(); $this->assertSame( 'Campaign Monitor', $ac_labels['name'] ); - $this->assertSame( 'list', $ac_labels['list'] ); + $this->assertSame( 'list or segment', $ac_labels['list'] ); $ac_labels = Newspack_Newsletters_Constant_Contact::get_labels(); $this->assertSame( 'Constant Contact', $ac_labels['name'] ); - $this->assertSame( 'list', $ac_labels['list'] ); + $this->assertSame( 'list or segment', $ac_labels['list'] ); } /** From a4751b3e7f503028f9386cccc6fb933c97256e56 Mon Sep 17 00:00:00 2001 From: dkoo Date: Tue, 3 Sep 2024 09:55:49 -0600 Subject: [PATCH 4/7] fix: use getter methods for Send_List classes --- ...ass-newspack-newsletters-campaign-monitor.php | 10 +++++----- ...ass-newspack-newsletters-constant-contact.php | 16 ++++++++-------- .../class-newspack-newsletters-mailchimp.php | 4 ++-- 3 files changed, 15 insertions(+), 15 deletions(-) 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 452ccaba5..11d8354fa 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 @@ -302,7 +302,7 @@ function( $segment ) { array_filter( $send_lists, function ( $list ) use ( $ids ) { - return Send_Lists::matches_id( $ids, $list->get( 'id' ) ); + return Send_Lists::matches_id( $ids, $list->get_id() ); } ) ); @@ -316,9 +316,9 @@ function ( $list ) use ( $search ) { return Send_Lists::matches_search( $search, [ - $list->get( 'id' ), - $list->get( 'name' ), - $list->get( 'entity_type' ), + $list->get_id(), + $list->get_name(), + $list->get_entity_type(), ] ); } @@ -480,7 +480,7 @@ public function format_campaign_args( $post_id ) { 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' ); + $send_mode = $send_list[0]->get_entity_type(); if ( 'list' === $send_mode ) { $args['ListIDs'] = [ $send_list_id ]; } elseif ( 'segment' === $send_mode ) { 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 907c24179..d3ef38d4d 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 @@ -687,10 +687,10 @@ public function get_sync_payload( $post ) { $send_lists = $this->get_send_lists( [ 'ids' => get_post_meta( $post->ID, 'send_list_id', true ) ] ); 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' ) ]; + 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() ]; } } @@ -1012,7 +1012,7 @@ function( $segment ) { array_filter( $send_lists, function ( $list ) use ( $ids ) { - return Send_Lists::matches_id( $ids, $list->get( 'id' ) ); + return Send_Lists::matches_id( $ids, $list->get_id() ); } ) ); @@ -1026,9 +1026,9 @@ function ( $list ) use ( $search ) { return Send_Lists::matches_search( $search, [ - $list->get( 'id' ), - $list->get( 'name' ), - $list->get( 'entity_type' ), + $list->get_id(), + $list->get_name(), + $list->get_entity_type(), ] ); } diff --git a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp.php b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp.php index d8fc49ee4..8c4dd067f 100644 --- a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp.php +++ b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp.php @@ -963,8 +963,8 @@ public function get_sync_payload( $post ) { 'type' => 'sublist', ] ); - if ( ! empty( $sublist[0]->get( 'entity_type' ) ) ) { - $sublist_type = $sublist[0]->get( 'entity_type' ); + if ( ! empty( $sublist[0]->get_entity_type() ) ) { + $sublist_type = $sublist[0]->get_entity_type(); switch ( $sublist_type ) { case 'group': $payload['recipients']['segment_opts'] = [ From b28ca0cce0467b7d07401bcfbe88e7a68ca90e6f Mon Sep 17 00:00:00 2001 From: dkoo Date: Fri, 6 Sep 2024 12:11:57 -0600 Subject: [PATCH 5/7] chore: clean up register_rest_route configs and permission callbacks --- includes/class-newspack-newsletters-ads.php | 12 +----------- ...lass-newspack-newsletters-subscription.php | 4 ++-- includes/class-newspack-newsletters.php | 9 --------- includes/class-send-lists.php | 12 +++++++----- ...newsletters-active-campaign-controller.php | 2 +- ...ewsletters-campaign-monitor-controller.php | 4 ++-- ...ewsletters-service-provider-controller.php | 4 ++-- ...-newspack-newsletters-service-provider.php | 19 ------------------- ...ewsletters-constant-contact-controller.php | 6 +++--- ...spack-newsletters-mailchimp-controller.php | 4 ++-- 10 files changed, 20 insertions(+), 56 deletions(-) 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-subscription.php b/includes/class-newspack-newsletters-subscription.php index 2867e6271..865d5f4f6 100644 --- a/includes/class-newspack-newsletters-subscription.php +++ b/includes/class-newspack-newsletters-subscription.php @@ -73,7 +73,7 @@ public static function register_api_endpoints() { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ __CLASS__, 'api_get_lists' ], - 'permission_callback' => [ 'Newspack_Newsletters', 'api_permission_callback' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_administration_permissions_check' ], ] ); register_rest_route( @@ -82,7 +82,7 @@ public static function register_api_endpoints() { [ 'methods' => \WP_REST_Server::EDITABLE, 'callback' => [ __CLASS__, 'api_update_lists' ], - 'permission_callback' => [ 'Newspack_Newsletters', 'api_permission_callback' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_administration_permissions_check' ], 'args' => [ 'lists' => [ 'type' => 'array', diff --git a/includes/class-newspack-newsletters.php b/includes/class-newspack-newsletters.php index afd621047..6e333f498 100644 --- a/includes/class-newspack-newsletters.php +++ b/includes/class-newspack-newsletters.php @@ -905,15 +905,6 @@ 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-lists.php b/includes/class-send-lists.php index f93037d46..c5e602952 100644 --- a/includes/class-send-lists.php +++ b/includes/class-send-lists.php @@ -8,10 +8,7 @@ namespace Newspack\Newsletters; use Newspack_Newsletters; -use Newspack_Newsletters_Settings; -use Newspack_Newsletters_Subscription; use WP_Error; -use WP_Post; defined( 'ABSPATH' ) || exit; @@ -61,7 +58,7 @@ public static function register_api_endpoints() { [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ __CLASS__, 'api_get_send_lists' ], - 'permission_callback' => [ 'Newspack_Newsletters', 'api_permission_callback' ], + 'permission_callback' => [ 'Newspack_Newsletters', 'api_administration_permissions_check' ], 'args' => [ 'ids' => [ 'type' => [ 'array', 'string' ], @@ -173,7 +170,12 @@ public static function api_get_send_lists( $request ) { } return \rest_ensure_response( - $provider->get_send_lists( $args ) + array_map( + function( $send_list ) { + return $send_list->to_array(); + }, + $provider->get_send_lists( $args ) + ) ); } } 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..64baaf2d3 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', 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 decfa2e81..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 @@ -37,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', @@ -52,7 +52,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/class-newspack-newsletters-service-provider-controller.php b/includes/service-providers/class-newspack-newsletters-service-provider-controller.php index 4d76cf8e1..6ddbca6d5 100644 --- a/includes/service-providers/class-newspack-newsletters-service-provider-controller.php +++ b/includes/service-providers/class-newspack-newsletters-service-provider-controller.php @@ -33,12 +33,12 @@ public function __construct( $service_provider ) { */ public function register_routes() { \register_rest_route( - $this->service_provider::BASE_NAMESPACE, + Newspack_Newsletters::API_NAMESPACE, '(?P[\a-z]+)/sync-error', [ 'methods' => \WP_REST_Server::READABLE, 'callback' => [ $this, 'api_get_sync_error' ], - '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/class-newspack-newsletters-service-provider.php b/includes/service-providers/class-newspack-newsletters-service-provider.php index 58991f842..ef659aa13 100644 --- a/includes/service-providers/class-newspack-newsletters-service-provider.php +++ b/includes/service-providers/class-newspack-newsletters-service-provider.php @@ -94,25 +94,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. * 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 7e697a111..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', 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 ea81de8fe..44ebbd7c0 100644 --- a/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-controller.php +++ b/includes/service-providers/mailchimp/class-newspack-newsletters-mailchimp-controller.php @@ -67,7 +67,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', @@ -82,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', From e300157dda3774e51688e90881a5ee32fe85ad5b Mon Sep 17 00:00:00 2001 From: dkoo Date: Fri, 6 Sep 2024 12:52:57 -0600 Subject: [PATCH 6/7] fix: improve error handling for get_send_list methods --- ...s-newspack-newsletters-active-campaign.php | 60 ++++++++++++------- ...-newspack-newsletters-campaign-monitor.php | 33 ++++++---- ...-newspack-newsletters-constant-contact.php | 23 +++++-- 3 files changed, 80 insertions(+), 36 deletions(-) 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 435098980..c958081ee 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 @@ -740,17 +740,49 @@ public function retrieve( $post_id, $skip_sync = false ) { $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', + ] + ); + if ( is_wp_error( $send_lists ) ) { + return $send_lists; + } + $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', + ] + ) : + []; + if ( is_wp_error( $send_sublists ) ) { + return $send_sublists; + } $newsletter_data = [ 'campaign' => true, // Satisfy the JS API. 'campaign_id' => $campaign_id, 'supports_multiple_test_recipients' => true, - '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', - ] - ), - 'sublists' => [], // Will be populated later if needed. + 'lists' => $send_lists, + 'sublists' => $send_sublists, ]; // Handle legacy sender meta. @@ -769,20 +801,6 @@ public function retrieve( $post_id, $skip_sync = false ) { } } - // 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; - } - } - 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; - } - } - if ( ! $campaign_id && true !== $skip_sync ) { $sync_result = $this->sync( get_post( $post_id ) ); if ( ! is_wp_error( $sync_result ) ) { 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 11d8354fa..ddd901811 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 @@ -259,6 +259,10 @@ public function get_send_lists( $args = [] ) { ); } + $lists = $this->get_lists(); + if ( is_wp_error( $lists ) ) { + return $lists; + } $send_lists = array_map( function( $list ) use ( $api_key ) { $config = [ @@ -277,9 +281,13 @@ function( $list ) use ( $api_key ) { return new Send_List( $config ); }, - $this->get_lists() + $lists ); - $segments = array_map( + $segments = $this->get_segments(); + if ( is_wp_error( $segments ) ) { + return $segments; + } + $send_segments = array_map( function( $segment ) { $segment_id = (string) $segment['id']; $config = [ @@ -292,9 +300,9 @@ function( $segment ) { return new Send_List( $config ); }, - $this->get_segments() + $segments ); - $send_lists = array_merge( $send_lists, $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']; @@ -338,6 +346,7 @@ function ( $list ) use ( $search ) { * * @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() ) { @@ -345,15 +354,19 @@ public function retrieve( $post_id ) { } try { $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', + ] + ); + 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' => $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', - ] - ), + 'lists' => $send_lists, ]; // Handle legacy sender meta. 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 d3ef38d4d..a7b6e404d 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 @@ -472,6 +472,7 @@ public function unset_segment( $post_id ) { * * @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() ) { @@ -511,12 +512,16 @@ public function retrieve( $post_id ) { } // Prefetch send list info if we have a selected list and/or sublist. - $newsletter_data['lists'] = $this->get_send_lists( + $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', ] ); + 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 ) { @@ -972,6 +977,10 @@ function ( $segment ) { * @return Send_List[]|WP_Error Array of Send_List objects on success, or WP_Error object on failure. */ public function get_send_lists( $args = [] ) { + $lists = $this->get_lists(); + if ( is_wp_error( $lists ) ) { + return $lists; + } $send_lists = array_map( function( $list ) { $config = [ @@ -986,9 +995,13 @@ function( $list ) { return new Send_List( $config ); }, - $this->get_lists() + $lists ); - $segments = array_map( + $segments = $this->get_segments(); + if ( is_wp_error( $segments ) ) { + return $segments; + } + $send_segments = array_map( function( $segment ) { $segment_id = (string) $segment['id']; $config = [ @@ -1002,9 +1015,9 @@ function( $segment ) { return new Send_List( $config ); }, - $this->get_segments() + $segments ); - $send_lists = array_merge( $send_lists, $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']; From 896c900cb704074e32dcd5055fbe1c1d0422a24b Mon Sep 17 00:00:00 2001 From: dkoo Date: Fri, 6 Sep 2024 13:45:35 -0600 Subject: [PATCH 7/7] fix: add error handling to send-lists API handler --- includes/class-send-lists.php | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/includes/class-send-lists.php b/includes/class-send-lists.php index c5e602952..388772b1e 100644 --- a/includes/class-send-lists.php +++ b/includes/class-send-lists.php @@ -31,7 +31,7 @@ public static function init() { return; } - add_action( 'rest_api_init', [ __CLASS__, 'register_api_endpoints' ] ); + \add_action( 'rest_api_init', [ __CLASS__, 'register_api_endpoints' ] ); } /** @@ -52,7 +52,7 @@ public static function should_initialize_send_lists() { * Register the endpoints needed to fetch send lists. */ public static function register_api_endpoints() { - register_rest_route( + \register_rest_route( Newspack_Newsletters::API_NAMESPACE, '/send-lists', [ @@ -168,14 +168,15 @@ public static function api_get_send_lists( $request ) { foreach ( $defaults as $key => $value ) { $args[ $key ] = $request[ $key ] ?? $value; } - + $send_lists = $provider->get_send_lists( $args ); return \rest_ensure_response( - array_map( - function( $send_list ) { - return $send_list->to_array(); - }, - $provider->get_send_lists( $args ) - ) + \is_wp_error( $send_lists ) ? $send_lists : + array_map( + function( $send_list ) { + return $send_list->to_array(); + }, + $send_lists + ) ); } }