Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
86.16% covered (warning)
86.16%
249 / 289
78.57% covered (warning)
78.57%
11 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Newsletter_Abilities
87.06% covered (warning)
87.06%
249 / 286
78.57% covered (warning)
78.57%
11 / 14
60.31
0.00% covered (danger)
0.00%
0 / 1
 get_category_slug
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_category_definition
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 get_abilities
100.00% covered (success)
100.00%
126 / 126
100.00% covered (success)
100.00%
1 / 1
1
 can_view_settings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 can_manage_settings
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_settings
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_subscriber_stats
20.93% covered (danger)
20.93%
9 / 43
0.00% covered (danger)
0.00%
0 / 1
96.54
 update_settings
100.00% covered (success)
100.00%
27 / 27
100.00% covered (success)
100.00%
1 / 1
9
 settings_map
100.00% covered (success)
100.00%
29 / 29
100.00% covered (success)
100.00%
1 / 1
1
 current_settings
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 read_option
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 normalize_input_value
93.75% covered (success)
93.75%
30 / 32
0.00% covered (danger)
0.00%
0 / 1
15.05
 cast_to_response
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
6.10
 invalid_field
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Jetpack Newsletter Abilities Registration
4 *
5 * Registers Jetpack Newsletter (subscriptions) abilities with the WordPress
6 * Abilities API.
7 *
8 * @package automattic/jetpack
9 */
10
11namespace Automattic\Jetpack\Plugin\Abilities;
12
13use Automattic\Jetpack\Connection\Client;
14use Automattic\Jetpack\Modules\Subscriptions\Settings as Subscriptions_Settings;
15use Automattic\Jetpack\WP_Abilities\Registrar;
16use Jetpack;
17use Jetpack_Options;
18
19if ( ! defined( 'ABSPATH' ) ) {
20    exit( 0 );
21}
22
23// The Subscriptions module doesn't load its Settings helpers eagerly. Pull it
24// in here so `Subscriptions_Settings::$default_reply_to` and
25// `is_valid_reply_to()` are resolvable when the abilities run.
26require_once __DIR__ . '/../class-settings.php';
27
28/**
29 * Registers Jetpack Newsletter abilities with the WordPress Abilities API.
30 *
31 * Exposes a consolidated read of the site's newsletter (subscriptions) settings
32 * and a partial-update writer so AI agents can configure the Newsletter module
33 * through the standard `wp-abilities/v1` REST surface.
34 */
35class Newsletter_Abilities extends Registrar {
36
37    // Field type tags used in `settings_map()`. Constants (not strings) so a
38    // typo in a `case` label fails fast instead of silently falling through.
39    private const TYPE_BOOL   = 'bool';
40    private const TYPE_ON_OFF = 'on_off';
41    private const TYPE_ENUM   = 'enum';
42    private const TYPE_STRING = 'string';
43
44    /**
45     * Allowed values for the `reply_to` setting. Mirror of
46     * `Subscriptions_Settings::is_valid_reply_to()` — kept here so it can be
47     * referenced from the JSON Schema enum without loading the Settings class
48     * at file-parse time.
49     */
50    private const REPLY_TO_VALUES = array( 'comment', 'author', 'no-reply' );
51
52    /**
53     * Returns the abilities category, definition, or registered abilities.
54     *
55     * @inheritDoc
56     */
57    public static function get_category_slug(): string {
58        return 'jetpack-newsletter';
59    }
60
61    /**
62     * Returns the abilities category, definition, or registered abilities.
63     *
64     * @inheritDoc
65     */
66    public static function get_category_definition(): array {
67        return array(
68            // "Jetpack" and "Newsletter" are product names and should not be translated.
69            'label'       => 'Jetpack Newsletter',
70            'description' => __( 'Abilities for reading and updating Jetpack Newsletter settings.', 'jetpack' ),
71        );
72    }
73
74    /**
75     * Returns the abilities category, definition, or registered abilities.
76     *
77     * @inheritDoc
78     */
79    public static function get_abilities(): array {
80        $settings_object_schema = array(
81            'type'                 => 'object',
82            'additionalProperties' => false,
83            'properties'           => array(
84                'subscribe_post_end_enabled' => array(
85                    'type'        => 'boolean',
86                    'description' => __( 'Show a "subscribe to blog" checkbox at the end of every post. Default true.', 'jetpack' ),
87                ),
88                'subscribe_comments_enabled' => array(
89                    'type'        => 'boolean',
90                    'description' => __( 'Show a "notify me of new comments" checkbox in the comment form. Default true.', 'jetpack' ),
91                ),
92                'notify_admin_on_subscribe'  => array(
93                    'type'        => 'boolean',
94                    'description' => __( 'Email the site admin whenever a new subscriber signs up. Default true.', 'jetpack' ),
95                ),
96                'reply_to'                   => array(
97                    'type'        => 'string',
98                    'enum'        => self::REPLY_TO_VALUES,
99                    'description' => __( 'Reply-to address for newsletter emails. "comment" routes to the post comment author, "author" to the post author, "no-reply" disables replies. Default "comment".', 'jetpack' ),
100                ),
101                'from_name'                  => array(
102                    'type'        => 'string',
103                    'description' => __( 'Sender name shown on newsletter emails. Empty string falls back to the site name.', 'jetpack' ),
104                    'maxLength'   => 200,
105                ),
106            ),
107        );
108
109        return array(
110            'jetpack-newsletter/get-settings'         => array(
111                'label'               => __( 'Get Newsletter settings', 'jetpack' ),
112                'description'         => __(
113                    'Return the current Jetpack Newsletter settings as a flat object. Always returns the same five fields: subscribe_post_end_enabled (bool), subscribe_comments_enabled (bool), notify_admin_on_subscribe (bool), reply_to ("comment"|"author"|"no-reply"), and from_name (string). Read-only and idempotent. To change any value, call jetpack-newsletter/update-settings.',
114                    'jetpack'
115                ),
116                'input_schema'        => array(
117                    'type'                 => 'object',
118                    'default'              => array(),
119                    'properties'           => array(),
120                    'additionalProperties' => false,
121                ),
122                'output_schema'       => $settings_object_schema,
123                'execute_callback'    => array( __CLASS__, 'get_settings' ),
124                'permission_callback' => array( __CLASS__, 'can_view_settings' ),
125                'meta'                => array(
126                    'annotations'  => array(
127                        'readonly'    => true,
128                        'destructive' => false,
129                        'idempotent'  => true,
130                    ),
131                    'show_in_rest' => true,
132                    'mcp'          => array(
133                        'public' => true,
134                        'type'   => 'tool', // default is already "tool", but can be explicit.
135                    ),
136                ),
137            ),
138
139            'jetpack-newsletter/update-settings'      => array(
140                'label'               => __( 'Update Newsletter settings', 'jetpack' ),
141                'description'         => __(
142                    'Update one or more Jetpack Newsletter settings. Any subset of the five fields may be supplied; omitted fields are left untouched. Idempotent — fields whose desired value already matches the current value are not rewritten. Returns { settings: <full current state after the update>, changed: <array of field names that actually transitioned> }. An empty input or input matching the current state returns changed = [].',
143                    'jetpack'
144                ),
145                'input_schema'        => $settings_object_schema,
146                'output_schema'       => array(
147                    'type'       => 'object',
148                    'properties' => array(
149                        'settings' => $settings_object_schema,
150                        'changed'  => array(
151                            'type'        => 'array',
152                            'items'       => array( 'type' => 'string' ),
153                            'description' => __( 'Names of the fields that actually changed during this call. Empty when the call was a no-op.', 'jetpack' ),
154                        ),
155                    ),
156                ),
157                'execute_callback'    => array( __CLASS__, 'update_settings' ),
158                'permission_callback' => array( __CLASS__, 'can_manage_settings' ),
159                'meta'                => array(
160                    'annotations'  => array(
161                        'readonly'    => false,
162                        'destructive' => false,
163                        'idempotent'  => true,
164                    ),
165                    'show_in_rest' => true,
166                    'mcp'          => array(
167                        'public' => true,
168                        'type'   => 'tool', // default is already "tool", but can be explicit.
169                    ),
170                ),
171            ),
172
173            'jetpack-newsletter/get-subscriber-stats' => array(
174                'label'               => __( 'Get Newsletter subscriber stats', 'jetpack' ),
175                'description'         => __(
176                    'Return aggregate subscriber counts for the site. Always returns { all: int, email: int, paid: int }: all is the total subscriber count (email + WordPress.com followers); email is the subset that receives email; paid is the subset on a paid newsletter plan. Numbers are fetched from WordPress.com and cached locally for one hour, so transient network errors yield a stale-but-non-zero response when one is available. Requires an active Jetpack connection — sites without one return jetpack_newsletter_not_connected.',
177                    'jetpack'
178                ),
179                'input_schema'        => array(
180                    'type'                 => 'object',
181                    'default'              => array(),
182                    'properties'           => array(),
183                    'additionalProperties' => false,
184                ),
185                'output_schema'       => array(
186                    'type'       => 'object',
187                    'properties' => array(
188                        'all'   => array( 'type' => 'integer' ),
189                        'email' => array( 'type' => 'integer' ),
190                        'paid'  => array( 'type' => 'integer' ),
191                    ),
192                ),
193                'execute_callback'    => array( __CLASS__, 'get_subscriber_stats' ),
194                'permission_callback' => array( __CLASS__, 'can_view_settings' ),
195                'meta'                => array(
196                    'annotations'  => array(
197                        'readonly'    => true,
198                        'destructive' => false,
199                        'idempotent'  => true,
200                    ),
201                    'show_in_rest' => true,
202                    'mcp'          => array(
203                        'public' => true,
204                        'type'   => 'tool', // default is already "tool", but can be explicit.
205                    ),
206                ),
207            ),
208        );
209    }
210
211    /**
212     * Permission check for read abilities. Newsletter settings live on the WP
213     * Newsletter settings screen, which is itself gated on `manage_options`.
214     */
215    public static function can_view_settings(): bool {
216        return current_user_can( 'manage_options' );
217    }
218
219    /**
220     * Permission check for write abilities. Mirrors the gating on the
221     * Newsletter settings screen.
222     */
223    public static function can_manage_settings(): bool {
224        return current_user_can( 'manage_options' );
225    }
226
227    /**
228     * Execute: return the current newsletter settings.
229     *
230     * @param array|null $input Unused — input schema accepts no parameters.
231     * @return array
232     */
233    public static function get_settings( $input = null ): array {
234        unset( $input );
235        return self::current_settings();
236    }
237
238    /**
239     * Transient key for the wpcom subscriber-stats response.
240     */
241    private const SUBSCRIBER_STATS_CACHE_KEY = 'jetpack_newsletter_subscriber_stats';
242
243    /**
244     * Transient TTL for subscriber-stats responses, in seconds.
245     *
246     * Matches the existing legacy widget pattern of an hour-long cache so
247     * agents calling this ability repeatedly don't fan out to wpcom.
248     */
249    private const SUBSCRIBER_STATS_CACHE_TTL = HOUR_IN_SECONDS;
250
251    /**
252     * Execute: fetch (and cache) aggregate subscriber counts from WordPress.com.
253     *
254     * @param array|null $input Unused — input schema accepts no parameters.
255     * @return array|\WP_Error
256     */
257    public static function get_subscriber_stats( $input = null ) {
258        unset( $input );
259
260        $cached = get_transient( self::SUBSCRIBER_STATS_CACHE_KEY );
261        if ( is_array( $cached ) ) {
262            return $cached;
263        }
264
265        if ( ! class_exists( 'Jetpack' ) || ! Jetpack::is_connection_ready() ) {
266            return new \WP_Error(
267                'jetpack_newsletter_not_connected',
268                __( 'Subscriber stats are only available on Jetpack-connected sites. Connect Jetpack and retry.', 'jetpack' )
269            );
270        }
271
272        $site_id = (int) Jetpack_Options::get_option( 'id' );
273        if ( $site_id <= 0 ) {
274            return new \WP_Error(
275                'jetpack_newsletter_not_connected',
276                __( 'No Jetpack site ID is registered. Connect Jetpack and retry.', 'jetpack' )
277            );
278        }
279
280        $response = Client::wpcom_json_api_request_as_blog(
281            sprintf( '/sites/%d/subscribers/stats', $site_id ),
282            '2',
283            array(),
284            null,
285            'wpcom'
286        );
287
288        if ( is_wp_error( $response ) ) {
289            return new \WP_Error(
290                'jetpack_newsletter_subscriber_stats_unavailable',
291                $response->get_error_message()
292            );
293        }
294
295        if ( 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
296            return new \WP_Error(
297                'jetpack_newsletter_subscriber_stats_unavailable',
298                __( 'WordPress.com did not return subscriber stats. Retry shortly.', 'jetpack' )
299            );
300        }
301
302        $body   = json_decode( wp_remote_retrieve_body( $response ), true );
303        $counts = is_array( $body ) && isset( $body['counts'] ) && is_array( $body['counts'] )
304            ? $body['counts']
305            : array();
306
307        $stats = array(
308            'all'   => isset( $counts['all_subscribers'] ) ? (int) $counts['all_subscribers'] : 0,
309            'email' => isset( $counts['email_subscribers'] ) ? (int) $counts['email_subscribers'] : 0,
310            'paid'  => isset( $counts['paid_subscribers'] ) ? (int) $counts['paid_subscribers'] : 0,
311        );
312
313        set_transient( self::SUBSCRIBER_STATS_CACHE_KEY, $stats, self::SUBSCRIBER_STATS_CACHE_TTL );
314
315        return $stats;
316    }
317
318    /**
319     * Execute: idempotent partial update of newsletter settings.
320     *
321     * Validates every supplied field before writing anything, so a malformed
322     * field cannot leave the option set in a partially-updated state.
323     *
324     * @param array|null $input Input matching the ability's input_schema.
325     * @return array|\WP_Error
326     */
327    public static function update_settings( $input = null ) {
328        $input = is_array( $input ) ? $input : array();
329        $map   = self::settings_map();
330
331        // Validate + normalize every supplied field up-front. Any failure
332        // short-circuits the call with no writes, so a bad field can't leave
333        // earlier fields in a partially-updated state.
334        $normalized = array();
335        foreach ( $map as $field => $config ) {
336            if ( ! array_key_exists( $field, $input ) ) {
337                continue;
338            }
339
340            $result = self::normalize_input_value( $field, $config, $input[ $field ] );
341            if ( $result instanceof \WP_Error ) {
342                return $result;
343            }
344            $normalized[ $field ] = $result;
345        }
346
347        // Read every field's current value once. This pass also feeds the
348        // post-update response, avoiding a second `get_option` sweep.
349        $current_storage = array();
350        foreach ( $map as $field => $config ) {
351            $current_storage[ $field ] = self::read_option( $config );
352        }
353
354        $changed = array();
355        foreach ( $normalized as $field => $desired ) {
356            // String-cast on both sides because every field's storage form is
357            // scalar (`0`/`1` for BOOL, `'on'`/`'off'` for ON_OFF, plain strings
358            // for ENUM/STRING). New field types added later must keep that
359            // invariant or this comparison will misfire.
360            if ( (string) $desired === (string) $current_storage[ $field ] ) {
361                continue;
362            }
363            update_option( $map[ $field ]['option'], $desired );
364            $current_storage[ $field ] = $desired;
365            $changed[]                 = $field;
366        }
367
368        $settings = array();
369        foreach ( $map as $field => $config ) {
370            $settings[ $field ] = self::cast_to_response( $config, $current_storage[ $field ] );
371        }
372
373        return array(
374            'settings' => $settings,
375            'changed'  => $changed,
376        );
377    }
378
379    /**
380     * Map of public ability field name → backing option config.
381     *
382     * Storage shape (option key, type tag, default, enum). The agent-facing
383     * descriptions and JSON Schema live in `get_abilities()`; this map drives
384     * the storage-side validation, normalization, and casting.
385     *
386     * Kept as a method (not a class constant) so the description strings
387     * referenced from `cast_to_response()` and `normalize_input_value()` can
388     * resolve through `__()` at call time rather than file load time.
389     */
390    private static function settings_map(): array {
391        return array(
392            'subscribe_post_end_enabled' => array(
393                'option'  => 'stb_enabled',
394                'type'    => self::TYPE_BOOL,
395                'default' => 1,
396            ),
397            'subscribe_comments_enabled' => array(
398                'option'  => 'stc_enabled',
399                'type'    => self::TYPE_BOOL,
400                'default' => 1,
401            ),
402            'notify_admin_on_subscribe'  => array(
403                'option'  => 'social_notifications_subscribe',
404                'type'    => self::TYPE_ON_OFF,
405                'default' => 'on',
406            ),
407            'reply_to'                   => array(
408                'option'  => 'jetpack_subscriptions_reply_to',
409                'type'    => self::TYPE_ENUM,
410                'default' => Subscriptions_Settings::$default_reply_to,
411                'enum'    => self::REPLY_TO_VALUES,
412            ),
413            'from_name'                  => array(
414                'option'     => 'jetpack_subscriptions_from_name',
415                'type'       => self::TYPE_STRING,
416                'default'    => '',
417                'max_length' => 200,
418            ),
419        );
420    }
421
422    /**
423     * Read all settings as the public response shape.
424     */
425    private static function current_settings(): array {
426        $out = array();
427        foreach ( self::settings_map() as $field => $config ) {
428            $out[ $field ] = self::cast_to_response( $config, self::read_option( $config ) );
429        }
430        return $out;
431    }
432
433    /**
434     * Read the raw option for a field config, falling back to its default.
435     *
436     * @param array $config Field config from `settings_map()`.
437     * @return mixed
438     */
439    private static function read_option( array $config ) {
440        return get_option( $config['option'], $config['default'] );
441    }
442
443    /**
444     * Validate + normalize a single input value to the storage form.
445     *
446     * @param string $field  Public field name (used in error messages).
447     * @param array  $config Field config from `settings_map()`.
448     * @param mixed  $value  Raw input value.
449     * @return mixed|\WP_Error Storage-form value, or WP_Error when invalid.
450     */
451    private static function normalize_input_value( string $field, array $config, $value ) {
452        switch ( $config['type'] ) {
453            case self::TYPE_BOOL:
454                if ( ! is_bool( $value ) ) {
455                    return self::invalid_field( $field, __( 'expected a boolean (true or false).', 'jetpack' ) );
456                }
457                return $value ? 1 : 0;
458
459            case self::TYPE_ON_OFF:
460                if ( ! is_bool( $value ) ) {
461                    return self::invalid_field( $field, __( 'expected a boolean (true or false).', 'jetpack' ) );
462                }
463                return $value ? 'on' : 'off';
464
465            case self::TYPE_ENUM:
466                // reply_to is the only enum today and shares its allowed-values
467                // list with `Subscriptions_Settings::is_valid_reply_to()`. Defer
468                // to that validator so the two surfaces can't drift.
469                $valid = 'reply_to' === $field
470                    ? Subscriptions_Settings::is_valid_reply_to( $value )
471                    : ( is_string( $value ) && in_array( $value, $config['enum'], true ) );
472                if ( ! $valid ) {
473                    return self::invalid_field(
474                        $field,
475                        sprintf(
476                            /* translators: %s: comma-separated list of allowed values. */
477                            __( 'allowed values are %s.', 'jetpack' ),
478                            implode( ', ', $config['enum'] )
479                        )
480                    );
481                }
482                return $value;
483
484            case self::TYPE_STRING:
485                if ( ! is_string( $value ) ) {
486                    return self::invalid_field( $field, __( 'expected a string.', 'jetpack' ) );
487                }
488                $sanitized = sanitize_text_field( $value );
489                if ( isset( $config['max_length'] ) && mb_strlen( $sanitized ) > (int) $config['max_length'] ) {
490                    return self::invalid_field(
491                        $field,
492                        sprintf(
493                            /* translators: %d: maximum number of characters. */
494                            __( 'must be %d characters or fewer.', 'jetpack' ),
495                            (int) $config['max_length']
496                        )
497                    );
498                }
499                return $sanitized;
500        }
501
502        return self::invalid_field( $field, __( 'unsupported field type.', 'jetpack' ) );
503    }
504
505    /**
506     * Cast a stored option value to the public response shape.
507     *
508     * @param array $config Field config from `settings_map()`.
509     * @param mixed $value  Raw stored value.
510     * @return mixed
511     */
512    private static function cast_to_response( array $config, $value ) {
513        switch ( $config['type'] ) {
514            case self::TYPE_BOOL:
515                return 1 === (int) $value;
516            case self::TYPE_ON_OFF:
517                return 'on' === (string) $value;
518            case self::TYPE_ENUM:
519                $value = (string) $value;
520                return in_array( $value, $config['enum'], true ) ? $value : (string) $config['default'];
521            case self::TYPE_STRING:
522                return (string) $value;
523        }
524        return $value;
525    }
526
527    /**
528     * Build a `jetpack_newsletter_invalid_<field>` WP_Error with a message
529     * that names the field and tells the agent how to fix the input.
530     *
531     * @param string $field  Public field name; appears in the error code and message.
532     * @param string $reason Translated explanation of the expected value.
533     * @return \WP_Error
534     */
535    private static function invalid_field( string $field, string $reason ): \WP_Error {
536        return new \WP_Error(
537            'jetpack_newsletter_invalid_' . $field,
538            sprintf(
539                /* translators: 1: field name, 2: explanation of the expected value. */
540                __( 'Invalid value for "%1$s": %2$s', 'jetpack' ),
541                $field,
542                $reason
543            )
544        );
545    }
546}