Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
75.73% covered (warning)
75.73%
465 / 614
23.53% covered (danger)
23.53%
4 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Subscribers_List
76.10% covered (warning)
76.10%
465 / 611
23.53% covered (danger)
23.53%
4 / 17
243.95
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
260 / 260
100.00% covered (success)
100.00%
1 / 1
3
 permission_check
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
2
 get_subscribers
0.00% covered (danger)
0.00%
0 / 36
0.00% covered (danger)
0.00%
0 / 1
90
 get_subscriber_totals
0.00% covered (danger)
0.00%
0 / 21
0.00% covered (danger)
0.00%
0 / 1
42
 remove_subscriber
95.24% covered (success)
95.24%
40 / 42
0.00% covered (danger)
0.00%
0 / 1
9
 send_bounce_confirmation
93.55% covered (success)
93.55%
29 / 31
0.00% covered (danger)
0.00%
0 / 1
11.03
 get_subscriber_individual
62.50% covered (warning)
62.50%
10 / 16
0.00% covered (danger)
0.00%
0 / 1
7.90
 get_subscriber_stats
66.67% covered (warning)
66.67%
10 / 15
0.00% covered (danger)
0.00%
0 / 1
5.93
 add_subscribers
96.00% covered (success)
96.00%
48 / 50
0.00% covered (danger)
0.00%
0 / 1
9
 get_import_jobs
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 reset_import_state
66.67% covered (warning)
66.67%
14 / 21
0.00% covered (danger)
0.00%
0 / 1
4.59
 get_memberships_products
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 add_comp
81.40% covered (warning)
81.40%
35 / 43
0.00% covered (danger)
0.00%
0 / 1
14.09
 remove_comp
0.00% covered (danger)
0.00%
0 / 26
0.00% covered (danger)
0.00%
0 / 1
56
 get_wpcom_error_message
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
8
 wpcom_get
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
1<?php
2/**
3 * Subscribers List REST endpoint.
4 *
5 * Proxies the WP.com `/wpcom/v2/sites/{blog_id}/subscribers` endpoint so the
6 * Subscribers Dashboard wp-admin page can render a live DataViews table on
7 * Jetpack-connected self-hosted sites.
8 *
9 * @package automattic/jetpack
10 */
11
12use Automattic\Jetpack\Connection\Client;
13use Automattic\Jetpack\Connection\Manager as Connection_Manager;
14
15if ( ! defined( 'ABSPATH' ) ) {
16    exit( 0 );
17}
18
19/**
20 * Class WPCOM_REST_API_V2_Endpoint_Subscribers_List
21 */
22class WPCOM_REST_API_V2_Endpoint_Subscribers_List extends WP_REST_Controller {
23
24    /**
25     * Constructor.
26     */
27    public function __construct() {
28        $this->namespace = 'wpcom/v2';
29        $this->rest_base = 'subscribers/list';
30
31        // On WPCOM the matching endpoint is registered via the wpcom-rest-api-v2 plugin loader,
32        // where it talks to subscriber storage directly.
33        $this->wpcom_is_wpcom_only_endpoint = true;
34
35        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
36    }
37
38    /**
39     * Register routes.
40     *
41     * Gated behind the same `rsm_jetpack_ui_modernization_newsletter` filter the dashboard UI uses
42     * (mirrors `Automattic\Jetpack\Newsletter\Settings::MODERNIZATION_FILTER`). Checked here, on
43     * `rest_api_init`, so theme-added filters have a chance to land before the gate evaluates.
44     *
45     * The filter defaults on for every site; hosts can opt out with
46     * `add_filter( 'rsm_jetpack_ui_modernization_newsletter', '__return_false' )`.
47     */
48    public function register_routes() {
49        if ( ! apply_filters( 'rsm_jetpack_ui_modernization_newsletter', true ) ) {
50            return;
51        }
52
53        register_rest_route(
54            $this->namespace,
55            '/subscribers/add',
56            array(
57                array(
58                    'methods'             => WP_REST_Server::CREATABLE,
59                    'callback'            => array( $this, 'add_subscribers' ),
60                    'permission_callback' => array( $this, 'permission_check' ),
61                    'args'                => array(
62                        'emails'     => array(
63                            'type'              => 'array',
64                            'items'             => array( 'type' => 'string' ),
65                            'required'          => true,
66                            'validate_callback' => function ( $value ) {
67                                return is_array( $value ) && count( $value ) > 0;
68                            },
69                        ),
70                        'categories' => array(
71                            'type'    => 'array',
72                            'items'   => array( 'type' => 'integer' ),
73                            'default' => array(),
74                        ),
75                    ),
76                ),
77            )
78        );
79
80        register_rest_route(
81            $this->namespace,
82            '/subscribers/remove',
83            array(
84                array(
85                    'methods'             => WP_REST_Server::CREATABLE,
86                    'callback'            => array( $this, 'remove_subscriber' ),
87                    'permission_callback' => array( $this, 'permission_check' ),
88                    'args'                => array(
89                        'user_id'               => array(
90                            'type'    => 'integer',
91                            'default' => 0,
92                            'minimum' => 0,
93                        ),
94                        'email_subscription_id' => array(
95                            'type'    => 'integer',
96                            'default' => 0,
97                            'minimum' => 0,
98                        ),
99                        'paid_subscription_ids' => array(
100                            'type'    => 'array',
101                            'items'   => array( 'type' => 'string' ),
102                            'default' => array(),
103                        ),
104                    ),
105                ),
106            )
107        );
108
109        register_rest_route(
110            $this->namespace,
111            '/subscribers/send-bounce-confirmation',
112            array(
113                array(
114                    'methods'             => WP_REST_Server::CREATABLE,
115                    'callback'            => array( $this, 'send_bounce_confirmation' ),
116                    'permission_callback' => array( $this, 'permission_check' ),
117                    'args'                => array(
118                        'email_address' => array(
119                            'type'     => 'string',
120                            'format'   => 'email',
121                            'required' => true,
122                        ),
123                    ),
124                ),
125            )
126        );
127
128        register_rest_route(
129            $this->namespace,
130            '/subscribers/individual',
131            array(
132                array(
133                    'methods'             => WP_REST_Server::READABLE,
134                    'callback'            => array( $this, 'get_subscriber_individual' ),
135                    'permission_callback' => array( $this, 'permission_check' ),
136                    'args'                => array(
137                        'subscription_id' => array(
138                            'type'    => 'integer',
139                            'default' => 0,
140                            'minimum' => 0,
141                        ),
142                        'user_id'         => array(
143                            'type'    => 'integer',
144                            'default' => 0,
145                            'minimum' => 0,
146                        ),
147                    ),
148                ),
149            )
150        );
151
152        register_rest_route(
153            $this->namespace,
154            '/subscribers/individual-stats',
155            array(
156                array(
157                    'methods'             => WP_REST_Server::READABLE,
158                    'callback'            => array( $this, 'get_subscriber_stats' ),
159                    'permission_callback' => array( $this, 'permission_check' ),
160                    'args'                => array(
161                        'subscription_id' => array(
162                            'type'    => 'integer',
163                            'default' => 0,
164                            'minimum' => 0,
165                        ),
166                        'user_id'         => array(
167                            'type'    => 'integer',
168                            'default' => 0,
169                            'minimum' => 0,
170                        ),
171                    ),
172                ),
173            )
174        );
175
176        register_rest_route(
177            $this->namespace,
178            '/subscribers/import',
179            array(
180                array(
181                    'methods'             => WP_REST_Server::READABLE,
182                    'callback'            => array( $this, 'get_import_jobs' ),
183                    'permission_callback' => array( $this, 'permission_check' ),
184                ),
185            )
186        );
187
188        register_rest_route(
189            $this->namespace,
190            '/subscribers/import/reset-state',
191            array(
192                array(
193                    'methods'             => WP_REST_Server::CREATABLE,
194                    'callback'            => array( $this, 'reset_import_state' ),
195                    'permission_callback' => array( $this, 'permission_check' ),
196                ),
197            )
198        );
199
200        register_rest_route(
201            $this->namespace,
202            '/subscribers/totals',
203            array(
204                array(
205                    'methods'             => WP_REST_Server::READABLE,
206                    'callback'            => array( $this, 'get_subscriber_totals' ),
207                    'permission_callback' => array( $this, 'permission_check' ),
208                ),
209            )
210        );
211
212        register_rest_route(
213            $this->namespace,
214            '/' . $this->rest_base,
215            array(
216                array(
217                    'methods'             => WP_REST_Server::READABLE,
218                    'callback'            => array( $this, 'get_subscribers' ),
219                    'permission_callback' => array( $this, 'permission_check' ),
220                    'args'                => array(
221                        'page'           => array(
222                            'type'    => 'integer',
223                            'default' => 1,
224                            'minimum' => 1,
225                        ),
226                        'per_page'       => array(
227                            'type'    => 'integer',
228                            'default' => 10,
229                            'minimum' => 1,
230                            'maximum' => 100,
231                        ),
232                        'sort'           => array(
233                            'type'    => 'string',
234                            'default' => 'date_subscribed',
235                            'enum'    => array( 'date_subscribed', 'name', 'plan', 'subscription_status' ),
236                        ),
237                        'sort_order'     => array(
238                            'type'    => 'string',
239                            'default' => 'desc',
240                            'enum'    => array( 'asc', 'desc' ),
241                        ),
242                        'search'         => array(
243                            'type'    => 'string',
244                            'default' => '',
245                        ),
246                        'filters'        => array(
247                            'type'    => 'array',
248                            'items'   => array( 'type' => 'string' ),
249                            'default' => array( 'all' ),
250                        ),
251                        'use_new_helper' => array(
252                            'type'    => 'boolean',
253                            'default' => true,
254                        ),
255                    ),
256                ),
257            )
258        );
259
260        register_rest_route(
261            $this->namespace,
262            '/subscribers/products',
263            array(
264                array(
265                    'methods'             => WP_REST_Server::READABLE,
266                    'callback'            => array( $this, 'get_memberships_products' ),
267                    'permission_callback' => array( $this, 'permission_check' ),
268                ),
269            )
270        );
271
272        register_rest_route(
273            $this->namespace,
274            '/subscribers/comp',
275            array(
276                array(
277                    'methods'             => WP_REST_Server::CREATABLE,
278                    'callback'            => array( $this, 'add_comp' ),
279                    'permission_callback' => array( $this, 'permission_check' ),
280                    'args'                => array(
281                        // Exactly one of user_id / email identifies the subscriber; add_comp()
282                        // enforces that, since the schema can't express "one or the other".
283                        'user_id'       => array(
284                            'type'    => 'integer',
285                            'default' => 0,
286                            'minimum' => 0,
287                        ),
288                        'email'         => array(
289                            'type'   => 'string',
290                            'format' => 'email',
291                        ),
292                        'plan_id'       => array(
293                            'type'     => 'integer',
294                            'required' => true,
295                            'minimum'  => 1,
296                        ),
297                        'no_expiration' => array(
298                            'type'    => 'boolean',
299                            'default' => false,
300                        ),
301                    ),
302                ),
303            )
304        );
305
306        register_rest_route(
307            $this->namespace,
308            '/subscribers/remove-comp',
309            array(
310                array(
311                    'methods'             => WP_REST_Server::CREATABLE,
312                    'callback'            => array( $this, 'remove_comp' ),
313                    'permission_callback' => array( $this, 'permission_check' ),
314                    'args'                => array(
315                        'comp_id' => array(
316                            'type'     => 'integer',
317                            'required' => true,
318                            'minimum'  => 1,
319                        ),
320                    ),
321                ),
322            )
323        );
324    }
325
326    /**
327     * Permission check — manage_options matches the wp-admin Subscribers menu cap.
328     *
329     * @return true|WP_Error
330     */
331    public function permission_check() {
332        if ( ! current_user_can( 'manage_options' ) ) {
333            return new WP_Error(
334                'authorization_required',
335                __( 'You are not allowed to view subscribers for this site.', 'jetpack' ),
336                array( 'status' => rest_authorization_required_code() )
337            );
338        }
339
340        return true;
341    }
342
343    /**
344     * Proxy GET /wpcom/v2/sites/{blog_id}/subscribers.
345     *
346     * @param WP_REST_Request $request Request.
347     * @return WP_REST_Response|WP_Error
348     */
349    public function get_subscribers( $request ) {
350        $blog_id = Connection_Manager::get_site_id();
351
352        if ( is_wp_error( $blog_id ) ) {
353            return $blog_id;
354        }
355
356        $query = array(
357            'page'           => (int) $request->get_param( 'page' ),
358            'per_page'       => (int) $request->get_param( 'per_page' ),
359            'sort'           => (string) $request->get_param( 'sort' ),
360            'sort_order'     => (string) $request->get_param( 'sort_order' ),
361            'use_new_helper' => $request->get_param( 'use_new_helper' ) ? 'true' : 'false',
362        );
363
364        $search = (string) $request->get_param( 'search' );
365        if ( '' !== $search ) {
366            $query['search'] = $search;
367        }
368
369        $query_string = http_build_query( $query );
370
371        $filters = (array) $request->get_param( 'filters' );
372        foreach ( $filters as $filter ) {
373            $query_string .= '&' . rawurlencode( 'filters[]' ) . '=' . rawurlencode( (string) $filter );
374        }
375
376        $path = sprintf( '/sites/%d/subscribers?%s', (int) $blog_id, $query_string );
377
378        $response = Client::wpcom_json_api_request_as_user(
379            $path,
380            '2',
381            array( 'method' => 'GET' ),
382            null,
383            'wpcom'
384        );
385
386        if ( is_wp_error( $response ) ) {
387            return $response;
388        }
389
390        $status = (int) wp_remote_retrieve_response_code( $response );
391        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
392
393        if ( $status >= 400 ) {
394            return new WP_Error(
395                'subscribers_list_failed',
396                is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not fetch subscribers.', 'jetpack' ),
397                array( 'status' => $status )
398            );
399        }
400
401        return rest_ensure_response( $body );
402    }
403
404    /**
405     * Proxy GET /wpcom/v2/sites/{blog_id}/subscribers/counts.
406     *
407     * @return WP_REST_Response|WP_Error
408     */
409    public function get_subscriber_totals() {
410        $blog_id = Connection_Manager::get_site_id();
411
412        if ( is_wp_error( $blog_id ) ) {
413            return $blog_id;
414        }
415
416        $response = Client::wpcom_json_api_request_as_user(
417            sprintf( '/sites/%d/subscribers/counts', (int) $blog_id ),
418            '2',
419            array( 'method' => 'GET' ),
420            null,
421            'wpcom'
422        );
423
424        if ( is_wp_error( $response ) ) {
425            return $response;
426        }
427
428        $status = (int) wp_remote_retrieve_response_code( $response );
429        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
430
431        if ( $status >= 400 ) {
432            return new WP_Error(
433                'subscribers_totals_failed',
434                is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not fetch subscriber totals.', 'jetpack' ),
435                array( 'status' => $status )
436            );
437        }
438
439        return rest_ensure_response( $body );
440    }
441
442    /**
443     * Remove a subscriber by cancelling any paid subscriptions and deleting both the WPCOM
444     * follower and email follower records, mirroring Calypso's `useSubscriberRemoveMutation`.
445     *
446     * Proxies to the consolidated wpcom `/sites/{blog_id}/subscribers/remove` (v2) endpoint, which
447     * runs all three steps in-process after switching to the blog and returns an aggregated
448     * `{ ok, errors }` result. Forwarded as the current user (not the blog token): the wpcom/v2
449     * authorization layer maps the Jetpack user token to the wpcom user, so the endpoint's
450     * `manage_options` gate evaluates against the acting admin — unlike the classic v1.1 `/rest`
451     * API, which left the request with no current user.
452     *
453     * @param WP_REST_Request $request Request.
454     * @return WP_REST_Response|WP_Error
455     */
456    public function remove_subscriber( $request ) {
457        $blog_id = Connection_Manager::get_site_id();
458
459        if ( is_wp_error( $blog_id ) ) {
460            return $blog_id;
461        }
462
463        $user_id               = (int) $request->get_param( 'user_id' );
464        $email_subscription_id = (int) $request->get_param( 'email_subscription_id' );
465        $paid_subscription_ids = array_values(
466            array_filter( array_map( 'strval', (array) $request->get_param( 'paid_subscription_ids' ) ) )
467        );
468
469        if ( ! $user_id && ! $email_subscription_id && empty( $paid_subscription_ids ) ) {
470            return new WP_Error(
471                'subscribers_remove_invalid',
472                __( 'No subscriber identifiers were provided.', 'jetpack' ),
473                array( 'status' => 400 )
474            );
475        }
476
477        $response = Client::wpcom_json_api_request_as_user(
478            sprintf( '/sites/%d/subscribers/remove', (int) $blog_id ),
479            '2',
480            array(
481                'method'  => 'POST',
482                'headers' => array( 'Content-Type' => 'application/json' ),
483            ),
484            wp_json_encode(
485                array(
486                    'user_id'               => $user_id,
487                    'email_subscription_id' => $email_subscription_id,
488                    'paid_subscription_ids' => $paid_subscription_ids,
489                ),
490                JSON_UNESCAPED_SLASHES
491            ),
492            'wpcom'
493        );
494
495        if ( is_wp_error( $response ) ) {
496            return $response;
497        }
498
499        $status = (int) wp_remote_retrieve_response_code( $response );
500        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
501
502        if ( $status >= 400 ) {
503            return new WP_Error(
504                'subscribers_remove_failed',
505                is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not remove the subscriber.', 'jetpack' ),
506                array( 'status' => $status )
507            );
508        }
509
510        return rest_ensure_response( $body );
511    }
512
513    /**
514     * Proxy POST /wpcom/v2/sites/{blog_id}/subscribers/send-bounce-confirmation.
515     *
516     * @since 16.4
517     *
518     * @param WP_REST_Request $request Request.
519     * @return WP_REST_Response|WP_Error
520     */
521    public function send_bounce_confirmation( $request ) {
522        $blog_id = Connection_Manager::get_site_id();
523
524        if ( is_wp_error( $blog_id ) ) {
525            return $blog_id;
526        }
527
528        $response = Client::wpcom_json_api_request_as_user(
529            sprintf( '/sites/%d/subscribers/send-bounce-confirmation', (int) $blog_id ),
530            '2',
531            array(
532                'method'  => 'POST',
533                'headers' => array( 'Content-Type' => 'application/json' ),
534            ),
535            wp_json_encode(
536                array(
537                    'email_address' => (string) $request->get_param( 'email_address' ),
538                ),
539                JSON_UNESCAPED_SLASHES
540            ),
541            'wpcom'
542        );
543
544        if ( is_wp_error( $response ) ) {
545            return $response;
546        }
547
548        $status = (int) wp_remote_retrieve_response_code( $response );
549        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
550
551        if ( $status >= 400 ) {
552            // Pass through wpcom's error code and data.
553            $data           = is_array( $body ) && isset( $body['data'] ) && is_array( $body['data'] ) ? $body['data'] : array();
554            $data['status'] = $status;
555
556            return new WP_Error(
557                is_array( $body ) && ! empty( $body['code'] ) ? (string) $body['code'] : 'subscribers_bounce_retry_failed',
558                is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'Could not send the confirmation email.', 'jetpack' ),
559                $data
560            );
561        }
562
563        return rest_ensure_response( $body );
564    }
565
566    /**
567     * Proxy GET /wpcom/v2/sites/{blog_id}/subscribers/individual.
568     *
569     * @param WP_REST_Request $request Request.
570     * @return WP_REST_Response|WP_Error
571     */
572    public function get_subscriber_individual( $request ) {
573        $blog_id         = Connection_Manager::get_site_id();
574        $subscription_id = (int) $request->get_param( 'subscription_id' );
575        $user_id         = (int) $request->get_param( 'user_id' );
576
577        if ( is_wp_error( $blog_id ) ) {
578            return $blog_id;
579        }
580
581        if ( ! $subscription_id && ! $user_id ) {
582            return new WP_Error(
583                'subscriber_individual_missing_id',
584                __( 'Provide either a subscription_id or a user_id.', 'jetpack' ),
585                array( 'status' => 400 )
586            );
587        }
588
589        $type  = $user_id ? 'wpcom' : 'email';
590        $query = $user_id
591            ? sprintf( 'user_id=%d&type=%s', $user_id, rawurlencode( $type ) )
592            : sprintf( 'subscription_id=%d&type=%s', $subscription_id, rawurlencode( $type ) );
593
594        return $this->wpcom_get( sprintf( '/sites/%d/subscribers/individual?%s', (int) $blog_id, $query ) );
595    }
596
597    /**
598     * Proxy GET /wpcom/v2/sites/{blog_id}/individual-subscriber-stats.
599     *
600     * @param WP_REST_Request $request Request.
601     * @return WP_REST_Response|WP_Error
602     */
603    public function get_subscriber_stats( $request ) {
604        $blog_id         = Connection_Manager::get_site_id();
605        $subscription_id = (int) $request->get_param( 'subscription_id' );
606        $user_id         = (int) $request->get_param( 'user_id' );
607
608        if ( is_wp_error( $blog_id ) ) {
609            return $blog_id;
610        }
611
612        if ( ! $subscription_id && ! $user_id ) {
613            return new WP_Error(
614                'subscriber_stats_missing_id',
615                __( 'Provide either a subscription_id or a user_id.', 'jetpack' ),
616                array( 'status' => 400 )
617            );
618        }
619
620        $query = $user_id
621            ? sprintf( 'user_id=%d', $user_id )
622            : sprintf( 'subscription_id=%d', $subscription_id );
623
624        return $this->wpcom_get( sprintf( '/sites/%d/individual-subscriber-stats?%s', (int) $blog_id, $query ) );
625    }
626
627    /**
628     * Add subscribers by email — proxies to `/sites/{blog_id}/subscribers/import` (v2), the same
629     * async import job Calypso's Add Subscribers modal starts. Addresses are imported directly as
630     * subscribers (no invitation email); WP.com processes the job in the background and emails the
631     * importing user a "Subscriber import completed" summary when it finishes.
632     *
633     * When the caller passes `categories` (newsletter category ids selected in the Add Subscribers
634     * picker), they're forwarded so the imported subscribers are opted into those categories — the
635     * same `categories` payload Calypso's `importCsvSubscribers` sends to this endpoint.
636     *
637     * @param WP_REST_Request $request Request.
638     * @return WP_REST_Response|WP_Error
639     */
640    public function add_subscribers( $request ) {
641        $blog_id = Connection_Manager::get_site_id();
642
643        if ( is_wp_error( $blog_id ) ) {
644            return $blog_id;
645        }
646
647        $emails = (array) $request->get_param( 'emails' );
648        $emails = array_values(
649            array_filter(
650                array_map( 'sanitize_email', $emails ),
651                static function ( $email ) {
652                    return is_email( $email );
653                }
654            )
655        );
656
657        if ( empty( $emails ) ) {
658            return new WP_Error(
659                'subscribers_add_no_valid_emails',
660                __( 'Provide at least one valid email address.', 'jetpack' ),
661                array( 'status' => 400 )
662            );
663        }
664
665        // Coerce category ids to positive integers and drop anything else — a category id is always
666        // a term id, so a zero or non-numeric value is noise we shouldn't forward to WP.com.
667        $categories = array_values(
668            array_filter(
669                array_map( 'absint', (array) $request->get_param( 'categories' ) )
670            )
671        );
672
673        $body = array(
674            'emails'     => $emails,
675            'parse_only' => false,
676        );
677        // Only include `categories` when the user actually selected some, so a plain import keeps
678        // the exact payload it sent before this feature existed.
679        if ( ! empty( $categories ) ) {
680            $body['categories'] = $categories;
681        }
682
683        // JSON body, not the form encoding Calypso submits: WP.com's Jetpack signature verifier
684        // canonicalizes `application/x-www-form-urlencoded` bodies differently from the Jetpack
685        // client (it re-encodes the parsed array as JSON before hashing), so a form-encoded POST
686        // fails the body-hash check and arrives unauthenticated (user 0) — surfacing as a 401
687        // `invalid_capabilities`. JSON bodies hash identically on both sides, and the endpoint
688        // reads its params from either encoding. `parse_only => false` runs the import rather
689        // than only validating the payload.
690        $response = Client::wpcom_json_api_request_as_user(
691            sprintf( '/sites/%d/subscribers/import', (int) $blog_id ),
692            '2',
693            array(
694                'method'  => 'POST',
695                'headers' => array( 'Content-Type' => 'application/json' ),
696            ),
697            wp_json_encode( $body, JSON_UNESCAPED_SLASHES ),
698            'wpcom'
699        );
700
701        if ( is_wp_error( $response ) ) {
702            return $response;
703        }
704
705        $status = (int) wp_remote_retrieve_response_code( $response );
706        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
707
708        // A successful import start carries the async job id as `upload_id`. Mirror Calypso, which
709        // treats any response without one as a failure even when the HTTP status is 2xx.
710        if ( $status >= 400 || ! is_array( $body ) || empty( $body['upload_id'] ) ) {
711            return new WP_Error(
712                'subscribers_add_failed',
713                $this->get_wpcom_error_message( $body, __( 'Could not add subscribers.', 'jetpack' ) ),
714                array( 'status' => $status >= 400 ? $status : 400 )
715            );
716        }
717
718        return rest_ensure_response( $body );
719    }
720
721    /**
722     * Proxy GET /wpcom/v2/sites/{blog_id}/subscribers/import — the site's subscriber import jobs,
723     * newest first. The dashboard polls this while the Add Subscribers modal is open so it can
724     * show the "import in progress" / stale-import notices (WP.com runs one import per site at a
725     * time).
726     *
727     * @return WP_REST_Response|WP_Error
728     */
729    public function get_import_jobs() {
730        $blog_id = Connection_Manager::get_site_id();
731
732        if ( is_wp_error( $blog_id ) ) {
733            return $blog_id;
734        }
735
736        return $this->wpcom_get( sprintf( '/sites/%d/subscribers/import', (int) $blog_id ) );
737    }
738
739    /**
740     * POST /wpcom/v2/subscribers/import/reset-state — cancel stuck (pending / importing)
741     * subscriber import jobs, mirroring Calypso's stale-import "Cancel import" action
742     * (`useSubscriberImportStatusReset`). Proxies to the wpcom
743     * `/sites/{blog_id}/subscribers/import/reset_state` endpoint and returns its
744     * `{ reset_count }` body.
745     *
746     * @return WP_REST_Response|WP_Error
747     */
748    public function reset_import_state() {
749        $blog_id = Connection_Manager::get_site_id();
750
751        if ( is_wp_error( $blog_id ) ) {
752            return $blog_id;
753        }
754
755        $response = Client::wpcom_json_api_request_as_user(
756            sprintf( '/sites/%d/subscribers/import/reset_state', (int) $blog_id ),
757            '2',
758            array( 'method' => 'POST' ),
759            null,
760            'wpcom'
761        );
762
763        if ( is_wp_error( $response ) ) {
764            return $response;
765        }
766
767        $status = (int) wp_remote_retrieve_response_code( $response );
768        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
769
770        if ( $status >= 400 ) {
771            return new WP_Error(
772                'subscribers_reset_import_failed',
773                $this->get_wpcom_error_message( $body, __( 'Could not cancel the import.', 'jetpack' ) ),
774                array( 'status' => $status )
775            );
776        }
777
778        return rest_ensure_response( $body );
779    }
780
781    /**
782     * Proxy GET /wpcom/v2/sites/{blog_id}/memberships/products?type=all&is_editable=true — the
783     * paid newsletter / membership tiers configured on this site. Used by the Comp-a-subscription
784     * plan picker so the modal can offer "comp this subscriber on plan X".
785     *
786     * @return WP_REST_Response|WP_Error
787     */
788    public function get_memberships_products() {
789        $blog_id = Connection_Manager::get_site_id();
790
791        if ( is_wp_error( $blog_id ) ) {
792            return $blog_id;
793        }
794
795        return $this->wpcom_get(
796            sprintf(
797                '/sites/%d/memberships/products?type=all&is_editable=true',
798                (int) $blog_id
799            )
800        );
801    }
802
803    /**
804     * POST /wpcom/v2/subscribers/comp — issue a complimentary subscription on a paid membership
805     * product for a single subscriber. Mirrors Calypso's `requestAddComp` thunk, which POSTs to
806     * `/sites/{id}/memberships/comps/{identifier}/{plan_id}`.
807     *
808     * The identifier is a wpcom user id, or an email address for a subscriber with no wpcom
809     * account — WP.com resolves (or creates) an account from the address and links the
810     * subscriber row. See NL-1033.
811     *
812     * @param WP_REST_Request $request Request.
813     * @return WP_REST_Response|WP_Error
814     */
815    public function add_comp( $request ) {
816        $blog_id = Connection_Manager::get_site_id();
817
818        if ( is_wp_error( $blog_id ) ) {
819            return $blog_id;
820        }
821
822        $user_id       = (int) $request->get_param( 'user_id' );
823        $email         = (string) $request->get_param( 'email' );
824        $plan_id       = (int) $request->get_param( 'plan_id' );
825        $no_expiration = (bool) $request->get_param( 'no_expiration' );
826
827        if ( ( $user_id && '' !== $email ) || ( ! $user_id && '' === $email ) ) {
828            return new WP_Error(
829                'subscribers_comp_invalid_subscriber',
830                __( 'Provide either a user ID or an email address for the subscriber.', 'jetpack' ),
831                array( 'status' => 400 )
832            );
833        }
834
835        $body = $no_expiration
836            ? wp_json_encode( array( 'no_expiration' => true ), JSON_UNESCAPED_SLASHES )
837            : null;
838
839        // WP.com rawurldecode()s this segment, so an email has to arrive encoded — `%d` would
840        // flatten it to 0.
841        $identifier = $user_id ? (string) $user_id : rawurlencode( $email );
842
843        $response = Client::wpcom_json_api_request_as_user(
844            sprintf(
845                '/sites/%d/memberships/comps/%s/%d',
846                (int) $blog_id,
847                $identifier,
848                $plan_id
849            ),
850            '2',
851            array(
852                'method'  => 'POST',
853                'headers' => array( 'Content-Type' => 'application/json' ),
854            ),
855            $body,
856            'wpcom'
857        );
858
859        if ( is_wp_error( $response ) ) {
860            return $response;
861        }
862
863        $status = (int) wp_remote_retrieve_response_code( $response );
864        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
865
866        // The Memberships API can report a failure either with an HTTP error status or with a 2xx
867        // response carrying an `error` payload (e.g. "User has already been comped this plan"), so
868        // treat both as failures and surface the upstream message rather than a generic one.
869        if ( $status >= 400 || ( is_array( $body ) && ! empty( $body['error'] ) ) ) {
870            return new WP_Error(
871                'subscribers_comp_failed',
872                $this->get_wpcom_error_message( $body, __( 'Could not comp the subscription.', 'jetpack' ) ),
873                array( 'status' => $status >= 400 ? $status : 400 )
874            );
875        }
876
877        return rest_ensure_response( $body );
878    }
879
880    /**
881     * POST /wpcom/v2/subscribers/remove-comp — revoke a complimentary subscription. Mirrors
882     * Calypso's `requestDeleteComp`, which DELETEs
883     * `/sites/{id}/memberships/comp/{compId}` (singular `comp`).
884     *
885     * @param WP_REST_Request $request Request.
886     * @return WP_REST_Response|WP_Error
887     */
888    public function remove_comp( $request ) {
889        $blog_id = Connection_Manager::get_site_id();
890
891        if ( is_wp_error( $blog_id ) ) {
892            return $blog_id;
893        }
894
895        $comp_id = (int) $request->get_param( 'comp_id' );
896
897        $response = Client::wpcom_json_api_request_as_user(
898            sprintf(
899                '/sites/%d/memberships/comp/%d',
900                (int) $blog_id,
901                $comp_id
902            ),
903            '2',
904            array( 'method' => 'DELETE' ),
905            null,
906            'wpcom'
907        );
908
909        if ( is_wp_error( $response ) ) {
910            return $response;
911        }
912
913        $status = (int) wp_remote_retrieve_response_code( $response );
914        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
915
916        // Mirror add_comp: the Memberships API can report a failure with an error status or with a
917        // 2xx response that carries an `error` payload, so treat both as failures.
918        if ( $status >= 400 || ( is_array( $body ) && ! empty( $body['error'] ) ) ) {
919            return new WP_Error(
920                'subscribers_remove_comp_failed',
921                $this->get_wpcom_error_message( $body, __( 'Could not remove the comp.', 'jetpack' ) ),
922                array( 'status' => $status >= 400 ? $status : 400 )
923            );
924        }
925
926        return rest_ensure_response( $body );
927    }
928
929    /**
930     * Extract the most specific human-readable error message from a wpcom Memberships API response
931     * body. The Memberships endpoints nest the reason under `error.message` (e.g. "User has already
932     * been comped this plan"); fall back to a top-level `message`, a string `error`, then the default.
933     *
934     * @param mixed  $body            Decoded response body.
935     * @param string $default_message Fallback used when the body carries no message.
936     * @return string Error message.
937     */
938    private function get_wpcom_error_message( $body, $default_message ) {
939        if ( is_array( $body ) ) {
940            if ( isset( $body['error']['message'] ) && is_string( $body['error']['message'] ) ) {
941                return $body['error']['message'];
942            }
943            if ( isset( $body['message'] ) && is_string( $body['message'] ) ) {
944                return $body['message'];
945            }
946            if ( isset( $body['error'] ) && is_string( $body['error'] ) ) {
947                return $body['error'];
948            }
949        }
950
951        return $default_message;
952    }
953
954    /**
955     * Helper: GET a wpcom v2 path on this site as the current user. Returns the parsed JSON
956     * response or a WP_Error.
957     *
958     * @param string $path Path under `/wpcom/v2`, including any query string.
959     * @return WP_REST_Response|WP_Error
960     */
961    private function wpcom_get( $path ) {
962        $response = Client::wpcom_json_api_request_as_user(
963            $path,
964            '2',
965            array( 'method' => 'GET' ),
966            null,
967            'wpcom'
968        );
969
970        if ( is_wp_error( $response ) ) {
971            return $response;
972        }
973
974        $status = (int) wp_remote_retrieve_response_code( $response );
975        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
976
977        if ( $status >= 400 ) {
978            return new WP_Error(
979                'wpcom_call_failed',
980                is_array( $body ) && isset( $body['message'] ) ? $body['message'] : __( 'WP.com call failed.', 'jetpack' ),
981                array( 'status' => $status )
982            );
983        }
984
985        return rest_ensure_response( $body );
986    }
987}
988
989wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Subscribers_List' );