Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
57.14% covered (warning)
57.14%
48 / 84
50.00% covered (danger)
50.00%
2 / 4
CRAP
0.00% covered (danger)
0.00%
0 / 1
WPCOM_REST_API_V2_Endpoint_Send_Email_Preview
59.26% covered (warning)
59.26%
48 / 81
50.00% covered (danger)
50.00%
2 / 4
50.82
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 register_routes
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
2
 permissions_check
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
42
 send_email_preview
51.52% covered (warning)
51.52%
17 / 33
0.00% covered (danger)
0.00%
0 / 1
28.41
1<?php
2/**
3 * Handles the sending of email previews via the WordPress.com REST API.
4 *
5 * @package automattic/jetpack
6 */
7
8use Automattic\Jetpack\Connection\Manager;
9use Automattic\Jetpack\Connection\Traits\WPCOM_REST_API_Proxy_Request;
10use Automattic\Jetpack\Status\Host;
11
12if ( ! defined( 'ABSPATH' ) ) {
13    exit( 0 );
14}
15
16/**
17 * Class WPCOM_REST_API_V2_Endpoint_Send_Email_Preview
18 * Handles the sending of email previews via the WordPress.com REST API
19 */
20class WPCOM_REST_API_V2_Endpoint_Send_Email_Preview extends WP_REST_Controller {
21
22    use WPCOM_REST_API_Proxy_Request;
23
24    /**
25     * Constructor.
26     */
27    public function __construct() {
28        $this->base_api_path                   = 'wpcom';
29        $this->version                         = 'v2';
30        $this->namespace                       = $this->base_api_path . '/' . $this->version;
31        $this->rest_base                       = '/send-email-preview';
32        $this->wpcom_is_wpcom_only_endpoint    = true;
33        $this->wpcom_is_site_specific_endpoint = true;
34
35        add_action( 'rest_api_init', array( $this, 'register_routes' ) );
36    }
37
38    /**
39     * Registers the routes for blogging prompts.
40     *
41     * @see register_rest_route()
42     */
43    public function register_routes() {
44        $options = array(
45            'show_in_index'       => true,
46            'methods'             => 'POST',
47            // if this is not a wpcom site, we need to proxy the request to wpcom
48            'callback'            => ( ( new Host() )->is_wpcom_simple() ) ? array(
49                $this,
50                'send_email_preview',
51            ) : array( $this, 'proxy_request_to_wpcom_as_user' ),
52            'permission_callback' => array( $this, 'permissions_check' ),
53            'args'                => array(
54                'id'    => array(
55                    'description' => __( 'Unique identifier for the post.', 'jetpack' ),
56                    'type'        => 'integer',
57                ),
58                'email' => array(
59                    'description' => __( 'Optional recipient address. Defaults to the current user. A different address is only accepted from users who may add subscribers, and is subject to the same abuse checks.', 'jetpack' ),
60                    'type'        => 'string',
61                ),
62            ),
63        );
64
65        register_rest_route(
66            $this->namespace,
67            $this->rest_base,
68            $options
69        );
70    }
71
72    /**
73     * Checks if the user is connected and has access to edit the post
74     *
75     * @param WP_REST_Request $request Full data about the request.
76     *
77     * @return true|WP_Error True if the request has edit access, WP_Error object otherwise.
78     */
79    public function permissions_check( $request ) {
80        if ( ! ( new Host() )->is_wpcom_simple() ) {
81            if ( ! ( new Manager() )->is_user_connected() ) {
82                return new WP_Error(
83                    'rest_cannot_send_email_preview',
84                    __( 'Please connect your user account to WordPress.com', 'jetpack' ),
85                    array( 'status' => rest_authorization_required_code() )
86                );
87            }
88        }
89
90        $post = get_post( $request->get_param( 'id' ) );
91
92        if ( is_wp_error( $post ) ) {
93            return $post;
94        }
95
96        if ( $post && ! current_user_can( 'edit_post', $post->ID ) ) {
97            return new WP_Error(
98                'rest_forbidden_context',
99                __( 'Please connect your user account to WordPress.com', 'jetpack' ),
100                array( 'status' => rest_authorization_required_code() )
101            );
102        }
103
104        return true;
105    }
106
107    /**
108     * Sends an email preview of a post to the current user.
109     *
110     * @param WP_REST_Request $request Full data about the request.
111     *
112     * @return WP_REST_Response|WP_Error Response object on success, or WP_Error object on failure.
113     */
114    public function send_email_preview( $request ) {
115        $post_id = $request['id'];
116        $post    = get_post( $post_id );
117
118        // Return error if the post cannot be retrieved
119        if ( is_wp_error( $post ) ) {
120            return $post;
121        }
122
123        // Check if the user's email is verified
124        if ( Email_Verification::is_email_unverified() ) {
125            return new WP_Error( 'unverified', __( 'Your email address must be verified.', 'jetpack' ), array( 'status' => rest_authorization_required_code() ) );
126        }
127
128        $current_user = wp_get_current_user();
129        $self_email   = $current_user->user_email;
130        $email        = $self_email;
131
132        // Resolve the recipient. The address defaults to the caller's own verified
133        // email; a caller-supplied address is only honored after it clears the same
134        // gates as adding that person as a subscriber. Self-sends keep their
135        // historical behavior and skip the guard entirely.
136        //
137        // The self-send fast path relies on the caller's own address matching
138        // $current_user->user_email. That holds because this callback only runs on
139        // wpcom (is_wpcom_simple(); Atomic/Jetpack requests are proxied to run as the
140        // wpcom user) — revisit this comparison if it ever runs in another context.
141        $requested = $request->get_param( 'email' );
142        if ( is_string( $requested ) && '' !== trim( $requested ) ) {
143            $requested = sanitize_email( $requested );
144
145            if ( ! is_email( $requested ) ) {
146                return new WP_Error( 'invalid_email', __( 'Please enter a valid email address.', 'jetpack' ), array( 'status' => 400 ) );
147            }
148
149            // Normalize both sides: comparing a sanitized address against the raw
150            // stored email could route a genuine self-send through the guard.
151            if ( 0 !== strcasecmp( $requested, sanitize_email( $self_email ) ) ) {
152                $guard = ABSPATH . 'wp-content/mu-plugins/email-subscriptions/email-preview-guard.php';
153                if ( ! class_exists( 'Email_Preview_Guard' ) && file_exists( $guard ) ) {
154                    require_once $guard;
155                }
156
157                if ( ! class_exists( 'Email_Preview_Guard' ) ) {
158                    return new WP_Error( 'send_email_preview_guard_unavailable', __( 'Test emails to another address are temporarily unavailable.', 'jetpack' ), array( 'status' => 503 ) );
159                }
160
161                $guarded = Email_Preview_Guard::check( $requested );
162                if ( is_wp_error( $guarded ) ) {
163                    return $guarded;
164                }
165
166                $email = $requested;
167            }
168        }
169
170        // Try to create a new subscriber with the resolved email
171        $subscriber = Blog_Subscriber::create( $email );
172        if ( ! $subscriber ) {
173            return new WP_Error( 'unverified', __( 'Could not create subscriber.', 'jetpack' ), array( 'status' => rest_authorization_required_code() ) );
174        }
175
176        // Send the post to the subscriber
177        require_once ABSPATH . 'wp-content/mu-plugins/email-subscriptions/subscription-mailer.php';
178        $mailer       = new Subscription_Mailer( $subscriber );
179        $subscription = $subscriber->get_subscription( get_current_blog_id() );
180
181        /**
182         * Fires immediately before an email preview is dispatched to the current user.
183         *
184         * Useful for inspecting the post content with an external classifier (e.g. an
185         * LLM-based content moderator) or for logging outbound previews. Fires after
186         * the subscriber has been resolved, so handlers receive a post that is about
187         * to be sent.
188         *
189         * @module subscriptions
190         *
191         * @since 15.8
192         *
193         * @param WP_Post                 $post         The post being previewed.
194         * @param Blog_Subscriber         $subscriber   The subscriber receiving the preview.
195         * @param Blog_Subscription|false $subscription The subscriber's subscription for the current blog, or false if none exists.
196         */
197        do_action( 'jetpack_before_send_email_preview', $post, $subscriber, $subscription );
198
199        $mailer->send_post( $post, $subscription );
200
201        // Return a response
202        return new WP_REST_Response( 'Email preview sent successfully.', 200 );
203    }
204}
205
206wpcom_rest_api_v2_load_plugin( 'WPCOM_REST_API_V2_Endpoint_Send_Email_Preview' );