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