PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 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 All 508 releases
← All changes | modules/subscriptions/abilities/class-newsletter-abilities.php +546 -0 16.2-beta → 16.3 View file →
@@ -1,0 +1,546 @@
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 +if ( ! 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.
26 +require_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 + */
35 +class 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 +}