PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.2
Jetpack – WP Security, Backup, Speed, & Growth v16.2
16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 13.7.2 13.8.3 13.9.2 14.0.1 14.1.1 14.2.2 All 502 releases
jetpack / modules / subscriptions / abilities / class-newsletter-abilities.php

class-newsletter-abilities.php in Jetpack – WP Security, Backup, Speed, & Growth 16.2, at modules/subscriptions/abilities/class-newsletter-abilities.php

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