Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.13% covered (warning)
79.13%
163 / 206
52.94% covered (warning)
52.94%
9 / 17
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Controller
79.90% covered (warning)
79.90%
163 / 204
52.94% covered (warning)
52.94%
9 / 17
77.67
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
81 / 81
100.00% covered (success)
100.00%
1 / 1
2
 can_manage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_status
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 status_data
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
4
 known_spf
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 enable_blockers
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
6
 setup
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
5.07
 check_dns
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
12
 update_settings
94.74% covered (success)
94.74%
18 / 19
0.00% covered (danger)
0.00%
0 / 1
7.01
 send_test
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
6
 get_usage
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
30
 get_log
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 get_log_entry
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 reset
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
5
 sanitize_domain
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 not_set_up
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 error_response
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
1<?php
2/**
3 * REST routes for the Send dashboard.
4 *
5 * @package automattic/jetpack-send
6 */
7
8namespace Automattic\Jetpack\Send;
9
10use WP_Error;
11use WP_REST_Request;
12use WP_REST_Server;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit( 0 );
16}
17
18/**
19 * `jetpack-send/v1` routes; all require `manage_options`.
20 */
21class REST_Controller {
22
23    const API_NAMESPACE = 'jetpack-send/v1';
24    const STATUS_PATH   = '/jetpack-send/v1/status';
25
26    /**
27     * Register the routes.
28     *
29     * @return void
30     */
31    public static function register_routes() {
32        $routes = array(
33            '/status'          => array( WP_REST_Server::READABLE, 'get_status', array() ),
34            '/setup'           => array(
35                WP_REST_Server::CREATABLE,
36                'setup',
37                array(
38                    'domain' => array(
39                        'type'     => 'string',
40                        'required' => true,
41                    ),
42                ),
43            ),
44            '/check-dns'       => array( WP_REST_Server::CREATABLE, 'check_dns', array() ),
45            '/settings'        => array(
46                WP_REST_Server::CREATABLE,
47                'update_settings',
48                array(
49                    'enabled'         => array( 'type' => 'boolean' ),
50                    'fallback'        => array( 'type' => 'boolean' ),
51                    'from_name'       => array( 'type' => 'string' ),
52                    'from_local_part' => array( 'type' => 'string' ),
53                ),
54            ),
55            '/test'            => array(
56                WP_REST_Server::CREATABLE,
57                'send_test',
58                array(
59                    'to' => array(
60                        'type'     => 'string',
61                        'format'   => 'email',
62                        'required' => true,
63                    ),
64                ),
65            ),
66            '/usage'           => array( WP_REST_Server::READABLE, 'get_usage', array() ),
67            '/log'             => array(
68                WP_REST_Server::READABLE,
69                'get_log',
70                array(
71                    'page'     => array(
72                        'type'    => 'integer',
73                        'minimum' => 1,
74                        'default' => 1,
75                    ),
76                    'per_page' => array(
77                        'type'    => 'integer',
78                        'minimum' => 1,
79                        'maximum' => 100,
80                        'default' => 20,
81                    ),
82                    'status'   => array(
83                        'type'    => 'string',
84                        'enum'    => array( '', Email_Log::STATUS_SENT, Email_Log::STATUS_FALLBACK, Email_Log::STATUS_FAILED ),
85                        'default' => '',
86                    ),
87                ),
88            ),
89            '/log/(?P<id>\d+)' => array(
90                WP_REST_Server::READABLE,
91                'get_log_entry',
92                array(
93                    'id' => array(
94                        'type'    => 'integer',
95                        'minimum' => 1,
96                    ),
97                ),
98            ),
99            '/reset'           => array( WP_REST_Server::CREATABLE, 'reset', array() ),
100        );
101
102        foreach ( $routes as $route => $definition ) {
103            list( $methods, $callback, $args ) = $definition;
104            register_rest_route(
105                self::API_NAMESPACE,
106                $route,
107                array(
108                    'methods'             => $methods,
109                    'callback'            => array( __CLASS__, $callback ),
110                    'permission_callback' => array( __CLASS__, 'can_manage' ),
111                    'args'                => $args,
112                )
113            );
114        }
115    }
116
117    /**
118     * Permission check.
119     *
120     * @return bool
121     */
122    public static function can_manage() {
123        return current_user_can( 'manage_options' );
124    }
125
126    /**
127     * GET /status.
128     *
129     * @return \WP_REST_Response
130     */
131    public static function get_status() {
132        return rest_ensure_response( self::status_data() );
133    }
134
135    /**
136     * The dashboard's status payload.
137     *
138     * @return array
139     */
140    public static function status_data() {
141        $provisioner = Provisioner_Factory::get();
142        $account     = Account::get();
143        $blockers    = self::enable_blockers( $account );
144
145        return array(
146            'provisioner'      => $provisioner ? $provisioner->get_type() : null,
147            'account'          => Account::to_public( $account ),
148            'settings'         => Settings::get(),
149            'can_enable'       => array() === $blockers,
150            'enable_blockers'  => $blockers,
151            'suggested_domain' => preg_replace( '/^www\./', '', strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) ),
152            'site_title'       => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ),
153            'admin_email'      => (string) get_option( 'admin_email' ),
154            'connect_url'      => class_exists( '\Automattic\Jetpack\My_Jetpack\Initializer' ) ? admin_url( 'admin.php?page=my-jetpack#/connection' ) : '',
155            'spf_suggestion'   => $account ? Dns_Check::spf_suggestion( self::known_spf( $account ) ) : null,
156        );
157    }
158
159    /**
160     * The domain's SPF record as last seen: by the latest DNS check, else at setup.
161     *
162     * @param array $account Stored account.
163     * @return string
164     */
165    private static function known_spf( array $account ) {
166        $check = $account['dns_check'] ?? null;
167        if ( is_array( $check ) && isset( $check['existing_spf'] ) ) {
168            return (string) $check['existing_spf'];
169        }
170        return (string) ( $account['existing_spf'] ?? '' );
171    }
172
173    /**
174     * Why sending can't be turned on yet; empty when it can.
175     *
176     * @param array|null $account Stored account.
177     * @return string[]
178     */
179    public static function enable_blockers( $account ) {
180        if ( ! $account ) {
181            return array( 'not_set_up' );
182        }
183        $check = $account['dns_check'] ?? null;
184        if ( ! is_array( $check ) ) {
185            return array( 'dns_not_checked' );
186        }
187
188        // DMARC needs one aligned record: DKIM when the domain has a key, otherwise SPF, which is optional only then.
189        $has_dkim = in_array( 'dkim', array_column( (array) ( $account['dns_records'] ?? array() ), 'purpose' ), true );
190        $required = array( 'domain_lockdown', 'sender_domain', $has_dkim ? 'dkim' : 'spf' );
191
192        $blockers = array();
193        foreach ( $required as $key ) {
194            if ( 'passed' !== ( $check[ $key ] ?? '' ) ) {
195                $blockers[] = $key;
196            }
197        }
198        return $blockers;
199    }
200
201    /**
202     * POST /setup.
203     *
204     * @param WP_REST_Request $request Request.
205     * @return \WP_REST_Response|WP_Error
206     */
207    public static function setup( WP_REST_Request $request ) {
208        $provisioner = Provisioner_Factory::get();
209        if ( ! $provisioner ) {
210            return new WP_Error( 'jetpack_send_no_provisioner', __( 'Connect Jetpack before setting up Send.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
211        }
212
213        $domain = self::sanitize_domain( (string) $request->get_param( 'domain' ) );
214        if ( '' === $domain ) {
215            return new WP_Error( 'jetpack_send_invalid_domain', __( 'Enter a domain you own, like example.com.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
216        }
217
218        $result  = $provisioner->provision( $domain );
219        $account = is_wp_error( $result ) ? $result : Account::from_provision( $result, $domain, $provisioner->get_type() );
220        if ( is_wp_error( $account ) ) {
221            return self::error_response( $account );
222        }
223
224        $account['existing_spf'] = Dns_Check::existing_spf( $domain );
225        Account::save( $account );
226        Settings::update( array( 'enabled' => false ) );
227        return rest_ensure_response( self::status_data() );
228    }
229
230    /**
231     * POST /check-dns.
232     *
233     * @return \WP_REST_Response|WP_Error
234     */
235    public static function check_dns() {
236        $account = Account::get();
237        if ( ! $account ) {
238            return self::not_set_up();
239        }
240
241        $response = ( new MailChannels_Client( $account['api_key'] ) )->check_domain( $account['domain'], (string) $account['dkim_selector'] );
242        if ( is_wp_error( $response ) ) {
243            return self::error_response( $response );
244        }
245
246        Account::update( array( 'dns_check' => Dns_Check::normalize( $response, Dns_Check::existing_spf( $account['domain'] ) ) ) );
247        return rest_ensure_response( self::status_data() );
248    }
249
250    /**
251     * POST /settings.
252     *
253     * @param WP_REST_Request $request Request.
254     * @return \WP_REST_Response|WP_Error
255     */
256    public static function update_settings( WP_REST_Request $request ) {
257        $changes = array();
258        foreach ( array( 'enabled', 'fallback', 'from_name', 'from_local_part' ) as $key ) {
259            if ( $request->has_param( $key ) ) {
260                $changes[ $key ] = $request->get_param( $key );
261            }
262        }
263
264        if ( ! empty( $changes['enabled'] ) ) {
265            $blockers = self::enable_blockers( Account::get() );
266            if ( $blockers ) {
267                return new WP_Error(
268                    'jetpack_send_cannot_enable',
269                    __( 'Finish setting up your domain before turning on sending.', 'jetpack-send-pkg' ),
270                    array(
271                        'status'   => 400,
272                        'blockers' => $blockers,
273                    )
274                );
275            }
276        }
277        if ( isset( $changes['from_local_part'] ) && ! is_email( $changes['from_local_part'] . '@example.com' ) ) {
278            return new WP_Error( 'jetpack_send_invalid_from', __( 'Enter a valid name for the From address, like hello.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
279        }
280
281        Settings::update( $changes );
282        return rest_ensure_response( self::status_data() );
283    }
284
285    /**
286     * POST /test.
287     *
288     * @param WP_REST_Request $request Request.
289     * @return \WP_REST_Response
290     */
291    public static function send_test( WP_REST_Request $request ) {
292        $result = Mailer::send_test( (string) $request->get_param( 'to' ) );
293        if ( is_wp_error( $result ) ) {
294            return rest_ensure_response(
295                array(
296                    'success' => false,
297                    'error'   => $result->get_error_message(),
298                )
299            );
300        }
301
302        return rest_ensure_response(
303            array(
304                'success'    => true,
305                'request_id' => $result['request_id'],
306            )
307        );
308    }
309
310    /**
311     * GET /usage.
312     *
313     * @return \WP_REST_Response|WP_Error
314     */
315    public static function get_usage() {
316        $account = Account::get();
317        if ( ! $account ) {
318            return self::not_set_up();
319        }
320
321        $provisioner = Provisioner_Factory::get();
322        if ( ! $provisioner || $provisioner->get_type() !== $account['provider'] ) {
323            return new WP_Error( 'jetpack_send_provider_unavailable', __( 'Usage is unavailable because the service that set up Send can no longer be reached.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
324        }
325
326        $usage = $provisioner->get_usage( $account );
327        return is_wp_error( $usage ) ? self::error_response( $usage ) : rest_ensure_response( $usage );
328    }
329
330    /**
331     * GET /log.
332     *
333     * @param WP_REST_Request $request Request.
334     * @return \WP_REST_Response
335     */
336    public static function get_log( WP_REST_Request $request ) {
337        return rest_ensure_response(
338            Email_Log::query( (int) $request->get_param( 'page' ), (int) $request->get_param( 'per_page' ), (string) $request->get_param( 'status' ) )
339        );
340    }
341
342    /**
343     * GET /log/{id}: one row with the content kept for previewing.
344     *
345     * @param WP_REST_Request $request Request.
346     * @return \WP_REST_Response|WP_Error
347     */
348    public static function get_log_entry( WP_REST_Request $request ) {
349        $entry = Email_Log::get( (int) $request->get_param( 'id' ) );
350        if ( ! $entry ) {
351            return new WP_Error( 'jetpack_send_log_entry_not_found', __( 'This email is no longer in the log.', 'jetpack-send-pkg' ), array( 'status' => 404 ) );
352        }
353        return rest_ensure_response( $entry );
354    }
355
356    /**
357     * POST /reset: clears local state even if deprovisioning fails, so the site is never stuck.
358     *
359     * @return \WP_REST_Response
360     */
361    public static function reset() {
362        $account     = Account::get();
363        $provisioner = Provisioner_Factory::get();
364        $warning     = '';
365        if ( $account && $provisioner && $provisioner->get_type() === $account['provider'] ) {
366            $result = $provisioner->deprovision( $account );
367            if ( is_wp_error( $result ) ) {
368                $warning = $result->get_error_message();
369            }
370        }
371
372        Account::delete();
373        Settings::update( array( 'enabled' => false ) );
374
375        $data                  = self::status_data();
376        $data['reset_warning'] = $warning;
377        return rest_ensure_response( $data );
378    }
379
380    /**
381     * Normalize user input to a bare lowercase domain, or '' when invalid.
382     *
383     * @param string $domain Input.
384     * @return string
385     */
386    public static function sanitize_domain( $domain ) {
387        $domain = strtolower( trim( $domain ) );
388        $domain = (string) preg_replace( '#^[a-z]+://#', '', $domain );
389        $domain = rtrim( explode( '/', $domain )[0], '.' );
390        return preg_match( '/^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $domain ) ? $domain : '';
391    }
392
393    /**
394     * The "not set up" error.
395     *
396     * @return WP_Error
397     */
398    private static function not_set_up() {
399        return new WP_Error( 'jetpack_send_not_set_up', __( 'Set up Send first.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
400    }
401
402    /**
403     * Give an upstream error a valid REST status.
404     *
405     * @param WP_Error $error Error.
406     * @return WP_Error
407     */
408    private static function error_response( WP_Error $error ) {
409        $data   = $error->get_error_data();
410        $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0;
411        return new WP_Error( $error->get_error_code(), $error->get_error_message(), array( 'status' => $status >= 400 ? $status : 502 ) );
412    }
413}