Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
97.42% covered (success)
97.42%
755 / 775
68.18% covered (warning)
68.18%
30 / 44
CRAP
0.00% covered (danger)
0.00%
0 / 1
REST_Controller
97.67% covered (success)
97.67%
755 / 773
68.18% covered (warning)
68.18%
30 / 44
239
0.00% covered (danger)
0.00%
0 / 1
 register_routes
100.00% covered (success)
100.00%
163 / 163
100.00% covered (success)
100.00%
1 / 1
4
 can_manage
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 list_arg
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 to_list
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
4
 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%
30 / 30
100.00% covered (success)
100.00%
1 / 1
5
 trimmed_before
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
4
 sender_avatar
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
9.04
 site_icon_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
3
 suggested_domain
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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
88.24% covered (warning)
88.24%
15 / 17
0.00% covered (danger)
0.00%
0 / 1
5.04
 check_dns
92.86% covered (success)
92.86%
13 / 14
0.00% covered (danger)
0.00%
0 / 1
5.01
 note_dns_check
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
1
 update_settings
97.92% covered (success)
97.92%
47 / 48
0.00% covered (danger)
0.00%
0 / 1
28
 setting_name
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
1
 apply_debug_change
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
9.24
 send_test
97.56% covered (success)
97.56%
40 / 41
0.00% covered (danger)
0.00%
0 / 1
14
 get_usage
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
6
 usage_data
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
5
 get_stats
94.12% covered (success)
94.12%
16 / 17
0.00% covered (danger)
0.00%
0 / 1
7.01
 stats_data
98.55% covered (success)
98.55%
68 / 69
0.00% covered (danger)
0.00%
0 / 1
26
 top_origins
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
4
 site_moment
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 get_log
94.44% covered (success)
94.44%
17 / 18
0.00% covered (danger)
0.00%
0 / 1
4.00
 log_window
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 log_row
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
4
 get_log_origins
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
2
 get_log_summary
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 avatar_url
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
2
 avatar_for_email
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
5
 first_recipient
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_log_entry
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
13
 support_text
97.22% covered (success)
97.22%
35 / 36
0.00% covered (danger)
0.00%
0 / 1
18
 resend_log_entry
98.08% covered (success)
98.08%
51 / 52
0.00% covered (danger)
0.00%
0 / 1
7
 resend_refused
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
3
 delete_log_entry
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 delete_log_entries
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 entry_not_found
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 reset
100.00% covered (success)
100.00%
17 / 17
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
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 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     * Transient that throttles POST /check-dns to one mail-service check per window.
28     *
29     * @var string
30     */
31    const CHECK_DNS_THROTTLE = 'jetpack_send_check_dns';
32
33    /**
34     * Seconds between mail-service checks from POST /check-dns.
35     *
36     * @var int
37     */
38    const CHECK_DNS_WINDOW = 60;
39
40    /**
41     * The widest `days` or `range` filter GET /log takes, about a hundred years.
42     *
43     * @var int
44     */
45    const MAX_LOG_DAYS = 36500;
46
47    /**
48     * The longest custom range GET /stats takes, in days.
49     *
50     * @var int
51     */
52    const MAX_STATS_DAYS = 365;
53
54    /**
55     * How the Source filter's values look: a kind of WordPress email, a plugin's folder or a theme's slug.
56     *
57     * @var string
58     */
59    const ORIGIN_PATTERN = '/^(core(:[a-z_]+)?|plugin:[A-Za-z0-9._-]+|theme:[A-Za-z0-9._-]+)$/';
60
61    /**
62     * The most rows POST /log/delete takes at once: one page of GET /log at its largest.
63     *
64     * @var int
65     */
66    const MAX_DELETE_IDS = 100;
67
68    /**
69     * Avatar size asked for, in pixels: three times the largest round avatar on the page.
70     *
71     * @var int
72     */
73    const AVATAR_SIZE = 120;
74
75    /**
76     * Register the routes.
77     *
78     * @return void
79     */
80    public static function register_routes() {
81        $id_arg   = array(
82            'id' => array(
83                'type'    => 'integer',
84                'minimum' => 1,
85            ),
86        );
87        $day      = array(
88            'type'    => 'string',
89            'pattern' => '^\\d{4}-\\d{2}-\\d{2}$',
90        );
91        $day_args = array(
92            'start' => $day,
93            'end'   => $day,
94        );
95        $routes   = array(
96            '/status'                 => array( WP_REST_Server::READABLE, 'get_status', array() ),
97            '/setup'                  => array(
98                WP_REST_Server::CREATABLE,
99                'setup',
100                array(
101                    'domain' => array(
102                        'type'     => 'string',
103                        'required' => true,
104                    ),
105                ),
106            ),
107            '/check-dns'              => array( WP_REST_Server::CREATABLE, 'check_dns', array() ),
108            '/settings'               => array(
109                WP_REST_Server::CREATABLE,
110                'update_settings',
111                array(
112                    'enabled'            => array( 'type' => 'boolean' ),
113                    'fallback'           => array( 'type' => 'boolean' ),
114                    'auto_enable'        => array( 'type' => 'boolean' ),
115                    'from_name'          => array( 'type' => 'string' ),
116                    'from_local_part'    => array( 'type' => 'string' ),
117                    'reply_to'           => array( 'type' => 'string' ),
118                    'onboarding'         => array( 'type' => 'object' ),
119                    'log_retention_days' => array(
120                        'type' => 'integer',
121                        'enum' => Settings::RETENTION_DAYS,
122                    ),
123                    'debug_log'          => array( 'type' => 'boolean' ),
124                    'debug_log_days'     => array(
125                        'type' => 'integer',
126                        'enum' => Settings::DEBUG_DAYS,
127                    ),
128                ),
129            ),
130            '/test'                   => array(
131                WP_REST_Server::CREATABLE,
132                'send_test',
133                array(
134                    'to' => array( 'type' => 'string' ),
135                ),
136            ),
137            '/usage'                  => array( WP_REST_Server::READABLE, 'get_usage', array() ),
138            '/stats'                  => array(
139                WP_REST_Server::READABLE,
140                'get_stats',
141                array(
142                    'range' => array(
143                        'type'    => 'integer',
144                        'enum'    => Period::PRESETS,
145                        'default' => 90,
146                    ),
147                ) + $day_args,
148            ),
149            '/log'                    => array(
150                WP_REST_Server::READABLE,
151                'get_log',
152                array(
153                    'page'     => array(
154                        'type'    => 'integer',
155                        'minimum' => 1,
156                        'default' => 1,
157                    ),
158                    'per_page' => array(
159                        'type'    => 'integer',
160                        'minimum' => 1,
161                        'maximum' => 100,
162                        'default' => 20,
163                    ),
164                    'search'   => array(
165                        'type'    => 'string',
166                        'default' => '',
167                    ),
168                    // Lists come as arrays, comma strings or one value; each matches any of its values.
169                    'status'   => self::list_arg( array( Email_Log::STATUS_SENT, Email_Log::STATUS_FALLBACK, Email_Log::STATUS_FAILED ) ),
170                    'origin'   => self::list_arg( array(), self::ORIGIN_PATTERN ),
171                    'source'   => self::list_arg( array( 'wp_mail', 'test', 'resend' ) ),
172                    // 1 is the last 24 hours, N the last N site-local days, counted as GET /stats counts them.
173                    'range'    => array(
174                        'type'    => 'integer',
175                        'minimum' => 1,
176                        'maximum' => self::MAX_LOG_DAYS,
177                    ),
178                    // Older links: 1 is today, N the same as `range`; 0 for any date.
179                    'days'     => array(
180                        'type'    => 'integer',
181                        'minimum' => 0,
182                        'maximum' => self::MAX_LOG_DAYS,
183                        'default' => 0,
184                    ),
185                ) + $day_args,
186            ),
187            '/log/origins'            => array( WP_REST_Server::READABLE, 'get_log_origins', array() ),
188            '/log/summary'            => array( WP_REST_Server::READABLE, 'get_log_summary', array() ),
189            '/log/(?P<id>\d+)'        => array(
190                array( WP_REST_Server::READABLE, 'get_log_entry', $id_arg ),
191                array( WP_REST_Server::DELETABLE, 'delete_log_entry', $id_arg ),
192            ),
193            '/log/(?P<id>\d+)/resend' => array(
194                WP_REST_Server::CREATABLE,
195                'resend_log_entry',
196                $id_arg + array(
197                    'to'  => array(
198                        'type'     => 'array',
199                        'required' => true,
200                        'minItems' => 1,
201                        'maxItems' => 100,
202                        'items'    => array( 'type' => 'string' ),
203                    ),
204                    'cc'  => array(
205                        'type'     => 'array',
206                        'default'  => array(),
207                        'maxItems' => 100,
208                        'items'    => array( 'type' => 'string' ),
209                    ),
210                    'bcc' => array(
211                        'type'     => 'array',
212                        'default'  => array(),
213                        'maxItems' => 100,
214                        'items'    => array( 'type' => 'string' ),
215                    ),
216                ),
217            ),
218            '/log/delete'             => array(
219                WP_REST_Server::CREATABLE,
220                'delete_log_entries',
221                array(
222                    'ids' => array(
223                        'type'     => 'array',
224                        'required' => true,
225                        'minItems' => 1,
226                        'maxItems' => self::MAX_DELETE_IDS,
227                        'items'    => array(
228                            'type'    => 'integer',
229                            'minimum' => 1,
230                        ),
231                    ),
232                ),
233            ),
234            '/reset'                  => array( WP_REST_Server::CREATABLE, 'reset', array() ),
235        );
236
237        foreach ( $routes as $route => $definition ) {
238            // A route with more than one method lists one definition per method.
239            $endpoints = array();
240            foreach ( is_array( $definition[0] ) ? $definition : array( $definition ) as $endpoint ) {
241                list( $methods, $callback, $args ) = $endpoint;
242                $endpoints[]                       = array(
243                    'methods'             => $methods,
244                    'callback'            => array( __CLASS__, $callback ),
245                    'permission_callback' => array( __CLASS__, 'can_manage' ),
246                    'args'                => $args,
247                );
248            }
249            register_rest_route( self::API_NAMESPACE, $route, $endpoints );
250        }
251    }
252
253    /**
254     * Permission check.
255     *
256     * @return bool
257     */
258    public static function can_manage() {
259        return current_user_can( 'manage_options' );
260    }
261
262    /**
263     * A list argument: an array (`status[0]=a`), a comma string (`status=a,b`) or one value, as a clean list.
264     * Older clients send '' for any.
265     *
266     * @since $$next-version$$
267     *
268     * @param string[] $allowed Values allowed; empty for any that match the pattern.
269     * @param string   $pattern Regular expression each value must match; '' for none.
270     * @return array The argument's definition.
271     */
272    public static function list_arg( array $allowed = array(), $pattern = '' ) {
273        return array(
274            'default'           => array(),
275            'validate_callback' => static function ( $value ) use ( $allowed, $pattern ) {
276                foreach ( self::to_list( $value ) as $item ) {
277                    if ( ( $allowed && ! in_array( $item, $allowed, true ) ) || ( '' !== $pattern && ! preg_match( $pattern, $item ) ) ) {
278                        /* translators: %s: a filter value. */
279                        return new WP_Error( 'rest_invalid_param', sprintf( __( 'Unknown value: %s', 'jetpack-send-pkg' ), $item ), array( 'status' => 400 ) );
280                    }
281                }
282                return true;
283            },
284            'sanitize_callback' => array( __CLASS__, 'to_list' ),
285        );
286    }
287
288    /**
289     * A list argument's values, without blanks or repeats.
290     *
291     * @since $$next-version$$
292     *
293     * @param mixed $value Array, comma string or one value.
294     * @return string[]
295     */
296    public static function to_list( $value ) {
297        $items = is_array( $value ) ? $value : explode( ',', is_scalar( $value ) ? (string) $value : '' );
298        $items = array_map( fn( $item ) => is_scalar( $item ) ? trim( (string) $item ) : '', $items );
299        return array_values( array_unique( array_filter( $items, 'strlen' ) ) );
300    }
301
302    /**
303     * GET /status.
304     *
305     * @return \WP_REST_Response
306     */
307    public static function get_status() {
308        return rest_ensure_response( self::status_data() );
309    }
310
311    /**
312     * The dashboard's status payload.
313     *
314     * @return array
315     */
316    public static function status_data() {
317        $provisioner = Provisioner_Factory::get();
318        $account     = Account::get();
319        $blockers    = self::enable_blockers( $account );
320
321        $settings                       = Settings::get();
322        $settings['log_retention_days'] = Email_Log::retention_setting();
323        $covers_from                    = Email_Log::oldest_created_at();
324        unset( $settings['debug_log_until'] );
325
326        return array(
327            'provisioner'          => $provisioner ? $provisioner->get_type() : null,
328            'account'              => Account::to_public( $account ),
329            'settings'             => $settings,
330            'can_enable'           => array() === $blockers,
331            'enable_blockers'      => $blockers,
332            'suggested_domain'     => self::suggested_domain(),
333            'site_title'           => wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ),
334            'admin_email'          => (string) get_option( 'admin_email' ),
335            'current_user_email'   => (string) wp_get_current_user()->user_email,
336            'connect_url'          => class_exists( '\Automattic\Jetpack\My_Jetpack\Initializer' ) ? admin_url( 'admin.php?page=my-jetpack#/connection' ) : '',
337            'spf_suggestion'       => $account ? Dns_Check::spf_suggestion( self::known_spf( $account ) ) : null,
338            'onboarding'           => Onboarding::state(),
339            'next_check_at'        => Dns_Watch::next_check_at(),
340            'sender_avatar'        => self::sender_avatar( $account ),
341            'site_icon_url'        => self::site_icon_url(),
342            'log_retention_days'   => Email_Log::retention_days(),
343            'log_retention_locked' => Email_Log::retention_locked(),
344            'log_covers_from'      => null === $covers_from ? null : Email_Log::iso( $covers_from ),
345            'log_trimmed_before'   => self::trimmed_before( $covers_from ),
346            'debug_log'            => Debug_Log::state(),
347            'has_history'          => Stats::has_history(),
348        );
349    }
350
351    /**
352     * The oldest email still in the log, when older ones were deleted from it: the counts reach an
353     * earlier site-local day. A new site's counts start the day its log does, so it gets null.
354     *
355     * @param string|null $covers_from The oldest row's UTC `Y-m-d H:i:s`.
356     * @return string|null ISO 8601 UTC.
357     */
358    private static function trimmed_before( $covers_from ) {
359        $since = Stats::since();
360        if ( null === $covers_from || null === $since ) {
361            return null;
362        }
363        $oldest = Period::local( (int) strtotime( $covers_from . ' UTC' ) )->format( 'Y-m-d' );
364        return Period::local( $since )->format( 'Y-m-d' ) < $oldest ? Email_Log::iso( $covers_from ) : null;
365    }
366
367    /**
368     * The From address's avatar, keyed by that address so the page uses it only while it shows the same one.
369     * Before setup the address is the one setup suggests: the admin's mailbox on the domain, else `wordpress@`.
370     *
371     * @param array|null $account Stored account.
372     * @return array{email: string, url: string}|null Null with avatars off or no valid address.
373     */
374    private static function sender_avatar( $account ) {
375        $settings = Settings::get();
376        $domain   = $account ? (string) $account['domain'] : self::suggested_domain();
377        $local    = (string) $settings['from_local_part'];
378        if ( ! $account && in_array( $local, array( '', 'wordpress' ), true ) ) {
379            $admin = explode( '@', strtolower( (string) get_option( 'admin_email' ) ) );
380            if ( 2 === count( $admin ) && $admin[1] === $domain && '' !== $admin[0] ) {
381                $local = $admin[0];
382            }
383        }
384        $email = is_email( $local . '@' . $domain );
385        $url   = is_string( $email ) ? self::avatar_for_email( $email ) : null;
386        return null === $url ? null : array(
387            'email' => $email,
388            'url'   => $url,
389        );
390    }
391
392    /**
393     * The site icon, which stands in for the sender when the From address has no avatar.
394     *
395     * @return string|null Null when the site has none.
396     */
397    private static function site_icon_url() {
398        $url = get_site_icon_url( self::AVATAR_SIZE );
399        return is_string( $url ) && '' !== $url ? $url : null;
400    }
401
402    /**
403     * The domain to suggest for sending: the site's host without `www.`.
404     *
405     * @return string
406     */
407    public static function suggested_domain() {
408        $domain = preg_replace( '/^www\./', '', strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) ) );
409
410        /**
411         * Filters the domain Send suggests for sending, e.g. for a site whose address is not the domain it sends from.
412         *
413         * @since $$next-version$$
414         *
415         * @param string $domain Lowercased host of the site address, without `www.`.
416         */
417        return strtolower( trim( (string) apply_filters( 'jetpack_send_suggested_domain', (string) $domain ) ) );
418    }
419
420    /**
421     * The domain's SPF record as last seen: by the latest DNS check, else at setup.
422     *
423     * @param array $account Stored account.
424     * @return string
425     */
426    private static function known_spf( array $account ) {
427        $check = $account['dns_check'] ?? null;
428        if ( is_array( $check ) && isset( $check['existing_spf'] ) ) {
429            return (string) $check['existing_spf'];
430        }
431        return (string) ( $account['existing_spf'] ?? '' );
432    }
433
434    /**
435     * Why sending can't be turned on yet; empty when it can.
436     *
437     * @param array|null $account Stored account.
438     * @return string[]
439     */
440    public static function enable_blockers( $account ) {
441        if ( ! $account ) {
442            return array( 'not_set_up' );
443        }
444        $check = $account['dns_check'] ?? null;
445        if ( ! is_array( $check ) ) {
446            return array( 'dns_not_checked' );
447        }
448
449        // DMARC needs one aligned record: DKIM when the domain has a key, otherwise SPF, which is optional only then.
450        $has_dkim = in_array( 'dkim', array_column( (array) ( $account['dns_records'] ?? array() ), 'purpose' ), true );
451        $required = array( 'domain_lockdown', 'sender_domain', $has_dkim ? 'dkim' : 'spf' );
452
453        $blockers = array();
454        foreach ( $required as $key ) {
455            if ( 'passed' !== ( $check[ $key ] ?? '' ) ) {
456                $blockers[] = $key;
457            }
458        }
459        return $blockers;
460    }
461
462    /**
463     * POST /setup.
464     *
465     * @param WP_REST_Request $request Request.
466     * @return \WP_REST_Response|WP_Error
467     */
468    public static function setup( WP_REST_Request $request ) {
469        $provisioner = Provisioner_Factory::get();
470        if ( ! $provisioner ) {
471            return new WP_Error( 'jetpack_send_no_provisioner', __( 'Connect Jetpack before setting up Send.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
472        }
473
474        $domain = self::sanitize_domain( (string) $request->get_param( 'domain' ) );
475        if ( '' === $domain ) {
476            return new WP_Error( 'jetpack_send_invalid_domain', __( 'Enter a domain you own, like example.com.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
477        }
478
479        $result  = $provisioner->provision( $domain );
480        $account = is_wp_error( $result ) ? $result : Account::from_provision( $result, $domain, $provisioner->get_type() );
481        if ( is_wp_error( $account ) ) {
482            return self::error_response( $account );
483        }
484
485        $account['existing_spf'] = Dns_Check::existing_spf( $domain );
486        Account::save( $account );
487        Settings::update( array( 'enabled' => false ) );
488        delete_transient( self::CHECK_DNS_THROTTLE );
489        Onboarding::restart( (string) wp_get_current_user()->user_email );
490        Dns_Watch::start();
491        return rest_ensure_response( self::status_data() );
492    }
493
494    /**
495     * POST /check-dns.
496     *
497     * @return \WP_REST_Response|WP_Error
498     */
499    public static function check_dns() {
500        $account = Account::get();
501        if ( ! $account ) {
502            return self::not_set_up();
503        }
504
505        // Inside the window, answer from the last check rather than asking the mail service again.
506        if ( false === get_transient( self::CHECK_DNS_THROTTLE ) ) {
507            $response = ( new MailChannels_Client( $account['api_key'] ) )->check_domain( $account['domain'], (string) $account['dkim_selector'] );
508            if ( is_wp_error( $response ) ) {
509                return self::error_response( $response );
510            }
511            // Throttle only after a real answer, so a failed call doesn't hide behind a stale result.
512            set_transient( self::CHECK_DNS_THROTTLE, time(), self::CHECK_DNS_WINDOW );
513            $check = Dns_Check::normalize( $response, Dns_Check::existing_spf( $account['domain'] ) );
514            Account::update( array( 'dns_check' => $check ) );
515            self::note_dns_check( (string) $account['domain'], $check );
516        }
517
518        // The user is watching, so no automatic test: they send one themselves.
519        if ( Onboarding::maybe_auto_enable() ) {
520            Dns_Watch::stop();
521        }
522        return rest_ensure_response( self::status_data() );
523    }
524
525    /**
526     * Note a domain check's verdicts in the debug log.
527     *
528     * @since $$next-version$$
529     *
530     * @param string $domain The sending domain.
531     * @param array  $check  Dns_Check::normalize()'s result.
532     * @return void
533     */
534    public static function note_dns_check( $domain, array $check ) {
535        Debug_Log::add(
536            'info',
537            'domain',
538            'dns.check',
539            /* translators: 1: the sending permission record's verdict, 2: the signing record's, 3: the sender list's; e.g. passed. */
540            sprintf( __( 'Checked the domain: permission %1$s, signing %2$s, sender list %3$s.', 'jetpack-send-pkg' ), $check['domain_lockdown'] ?? '', $check['dkim'] ?? '', $check['spf'] ?? '' ),
541            array(
542                'domain'        => $domain,
543                'lockdown'      => $check['domain_lockdown'] ?? '',
544                'dkim'          => $check['dkim'] ?? '',
545                'spf'           => $check['spf'] ?? '',
546                'sender_domain' => $check['sender_domain'] ?? '',
547            )
548        );
549    }
550
551    /**
552     * POST /settings.
553     *
554     * @param WP_REST_Request $request Request.
555     * @return \WP_REST_Response|WP_Error
556     */
557    public static function update_settings( WP_REST_Request $request ) {
558        $changes = array();
559        foreach ( array( 'enabled', 'fallback', 'auto_enable', 'from_name', 'from_local_part', 'reply_to', 'log_retention_days', 'debug_log_days' ) as $key ) {
560            if ( $request->has_param( $key ) ) {
561                $changes[ $key ] = $request->get_param( $key );
562            }
563        }
564        $onboarding = $request->has_param( 'onboarding' ) ? $request->get_param( 'onboarding' ) : null;
565        $debug      = $request->has_param( 'debug_log' ) ? (bool) $request->get_param( 'debug_log' ) : null;
566
567        if ( isset( $changes['log_retention_days'] ) && Email_Log::retention_locked() ) {
568            return new WP_Error( 'jetpack_send_retention_locked', __( 'Your site’s code sets how long emails are kept.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
569        }
570
571        if ( ! empty( $changes['enabled'] ) ) {
572            $blockers = self::enable_blockers( Account::get() );
573            if ( $blockers ) {
574                return new WP_Error(
575                    'jetpack_send_cannot_enable',
576                    __( 'Finish setting up your domain before turning on sending.', 'jetpack-send-pkg' ),
577                    array(
578                        'status'   => 400,
579                        'blockers' => $blockers,
580                    )
581                );
582            }
583        }
584        if ( isset( $changes['from_local_part'] ) && ! is_email( $changes['from_local_part'] . '@example.com' ) ) {
585            return new WP_Error( 'jetpack_send_invalid_from', __( 'Use letters, numbers, dots or dashes.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
586        }
587        if ( isset( $changes['reply_to'] ) && '' !== trim( (string) $changes['reply_to'] ) && ! is_email( trim( (string) $changes['reply_to'] ) ) ) {
588            return new WP_Error( 'jetpack_send_invalid_reply_to', __( 'Enter an email address for replies, like you@example.com.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
589        }
590        if ( null !== $onboarding ) {
591            $invalid = Onboarding::validate_client( $onboarding );
592            if ( $invalid ) {
593                return $invalid;
594            }
595        }
596
597        $kept_before = Email_Log::retention_days();
598        $debug_days  = (int) Settings::get()['debug_log_days'];
599        Settings::update( $changes );
600        if ( ! empty( $changes['enabled'] ) ) {
601            Onboarding::note_enabled();
602        }
603        if ( null !== $onboarding ) {
604            Onboarding::update_from_client( array_intersect_key( $onboarding, array_flip( Onboarding::CLIENT_KEYS ) ) );
605        }
606        self::apply_debug_change( $debug, isset( $changes['debug_log_days'] ) && (int) $changes['debug_log_days'] !== $debug_days );
607        if ( $changes || null !== $debug ) {
608            $keys = array_keys( $changes + ( null === $debug ? array() : array( 'debug_log' => $debug ) ) );
609            // The switch sends its days with it, so one change isn't named twice.
610            if ( null !== $debug ) {
611                $keys = array_values( array_diff( $keys, array( 'debug_log_days' ) ) );
612            }
613            /* translators: %s: the settings that changed, e.g. "backup mail, how long the log keeps emails". */
614            Debug_Log::add( 'info', 'settings', 'settings.changed', sprintf( __( 'Changed %s.', 'jetpack-send-pkg' ), implode( ', ', array_map( array( __CLASS__, 'setting_name' ), $keys ) ) ), array( 'keys' => $keys ) );
615        }
616
617        $data = self::status_data();
618        // A shorter retention deletes older emails now, and the rest in the background if there are many.
619        $kept_now = Email_Log::retention_days();
620        if ( isset( $changes['log_retention_days'] ) && $kept_now > 0 && ( 0 === $kept_before || $kept_now < $kept_before ) ) {
621            $data['pruned'] = Email_Log::prune( 10 );
622            if ( $data['pruned']['remaining'] > 0 ) {
623                wp_schedule_single_event( time(), Email_Log::PRUNE_HOOK );
624            }
625            $data = array_merge( self::status_data(), array( 'pruned' => $data['pruned'] ) );
626        }
627        return rest_ensure_response( $data );
628    }
629
630    /**
631     * A setting's plain name for the debug log; its key when it has none.
632     *
633     * @param string $key Setting key.
634     * @return string
635     */
636    private static function setting_name( $key ) {
637        $names = array(
638            'enabled'            => __( 'sending', 'jetpack-send-pkg' ),
639            'fallback'           => __( 'backup mail', 'jetpack-send-pkg' ),
640            'auto_enable'        => __( 'turning sending on by itself', 'jetpack-send-pkg' ),
641            'from_name'          => __( 'the sender name', 'jetpack-send-pkg' ),
642            'log_retention_days' => __( 'how long the log keeps emails', 'jetpack-send-pkg' ),
643            'debug_log'          => __( 'the debug log', 'jetpack-send-pkg' ),
644            'debug_log_days'     => __( 'how long the debug log records', 'jetpack-send-pkg' ),
645        );
646        return $names[ $key ] ?? (string) $key;
647    }
648
649    /**
650     * Turn the debug log on or off, or restart it when its days changed while it's on.
651     *
652     * @param bool|null $turn_on      True or false from the request; null when it wasn't sent.
653     * @param bool      $days_changed Whether `debug_log_days` changed.
654     * @return void
655     */
656    private static function apply_debug_change( $turn_on, $days_changed ) {
657        $days = (int) Settings::get()['debug_log_days'];
658        if ( true === $turn_on && ( ! Debug_Log::enabled() || $days_changed ) ) {
659            Debug_Log::turn_on( $days );
660        } elseif ( false === $turn_on && ! empty( Settings::get()['debug_log'] ) ) {
661            Debug_Log::turn_off();
662        } elseif ( null === $turn_on && $days_changed && Debug_Log::enabled() ) {
663            Debug_Log::turn_on( $days );
664        }
665    }
666
667    /**
668     * POST /test: one test email to the current user, once the domain is ready, rate-limited per site.
669     *
670     * @param WP_REST_Request $request Request.
671     * @return \WP_REST_Response|WP_Error
672     */
673    public static function send_test( WP_REST_Request $request ) {
674        $account = Account::get();
675        if ( ! $account ) {
676            return self::not_set_up();
677        }
678
679        // Only ever the signed-in user's own address, so the route can't be used to relay mail.
680        $me = (string) wp_get_current_user()->user_email;
681        $to = $request->has_param( 'to' ) ? trim( (string) $request->get_param( 'to' ) ) : $me;
682        if ( '' === $me || ! is_email( $me ) || strtolower( $to ) !== strtolower( $me ) ) {
683            return new WP_Error( 'jetpack_send_bad_recipient', __( 'Test emails can only go to your own email address.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
684        }
685
686        if ( array() !== self::enable_blockers( $account ) ) {
687            return new WP_Error(
688                'jetpack_send_not_ready',
689                /* translators: %s: the domain emails are sent from, e.g. example.com. */
690                sprintf( __( 'Finish connecting %s first.', 'jetpack-send-pkg' ), $account['domain'] ),
691                array( 'status' => 400 )
692            );
693        }
694
695        if ( ! Onboarding::claim_test() ) {
696            return new WP_Error(
697                'jetpack_send_rate_limited',
698                __( 'You’ve sent a few tests already. Try again in a few minutes.', 'jetpack-send-pkg' ),
699                array(
700                    'status'      => 429,
701                    'retry_after' => Onboarding::test_retry_after(),
702                )
703            );
704        }
705
706        $result = Mailer::send_test( $me );
707        if ( is_wp_error( $result ) ) {
708            $data     = $result->get_error_data();
709            $response = array(
710                'success' => false,
711                'error'   => $result->get_error_message(),
712            );
713            if ( is_array( $data ) && ! empty( $data['log_id'] ) ) {
714                $response['log_id'] = (string) $data['log_id'];
715            }
716            if ( is_array( $data ) && ! empty( $data['reason'] ) ) {
717                $response['reason'] = (string) $data['reason'];
718            }
719            return rest_ensure_response( $response );
720        }
721
722        $response = array(
723            'success'    => true,
724            'request_id' => $result['request_id'],
725        );
726        if ( '' !== $result['log_id'] ) {
727            $response['log_id'] = $result['log_id'];
728        }
729        return rest_ensure_response( $response );
730    }
731
732    /**
733     * GET /usage.
734     *
735     * @return \WP_REST_Response|WP_Error
736     */
737    public static function get_usage() {
738        $account = Account::get();
739        if ( ! $account ) {
740            return self::not_set_up();
741        }
742        return rest_ensure_response( self::usage_data( $account ) );
743    }
744
745    /**
746     * This month's emails, from the dashboard's counts so deleting emails from the log doesn't lower it,
747     * plus the plan's limit when the mail service gives one. The service's own count isn't shown: it can
748     * lag behind sends that already went out.
749     *
750     * @param array    $account Stored account.
751     * @param int|null $now     Unix time; defaults to now.
752     * @return array{emails: int, tests: int, month_start: string, resets_on: string, monthly_limit: int, total_usage: int|null, period_start: string, period_end: string}
753     */
754    public static function usage_data( array $account, $now = null ) {
755        $month_start = self::site_moment( $now )->format( 'Y-m-01' );
756        $counts      = Stats::sent_counts( Period::day_start( $month_start ) );
757        $data        = array(
758            'emails'        => $counts['emails'],
759            'tests'         => $counts['tests'],
760            'month_start'   => $month_start,
761            'resets_on'     => ( new \DateTimeImmutable( $month_start, new \DateTimeZone( 'UTC' ) ) )->modify( 'first day of next month' )->format( 'Y-m-d' ),
762            'monthly_limit' => (int) ( $account['monthly_limit'] ?? 0 ),
763            'total_usage'   => null,
764            'period_start'  => '',
765            'period_end'    => '',
766        );
767
768        $provisioner = Provisioner_Factory::get();
769        $usage       = $provisioner && $provisioner->get_type() === $account['provider'] ? $provisioner->get_usage( $account ) : null;
770        if ( is_array( $usage ) ) {
771            $data['monthly_limit'] = (int) ( $usage['monthly_limit'] ?? $data['monthly_limit'] );
772            $data['total_usage']   = isset( $usage['total_usage'] ) ? (int) $usage['total_usage'] : null;
773            $data['period_start']  = (string) ( $usage['period_start'] ?? '' );
774            $data['period_end']    = (string) ( $usage['period_end'] ?? '' );
775        }
776        return $data;
777    }
778
779    /**
780     * GET /stats: email activity for a preset range or custom days, in the site's timezone, from the
781     * dashboard's counts. Tests are counted apart.
782     *
783     * @param WP_REST_Request $request Request.
784     * @return \WP_REST_Response|WP_Error
785     */
786    public static function get_stats( WP_REST_Request $request ) {
787        if ( ! Account::get() ) {
788            return self::not_set_up();
789        }
790        $custom = $request->has_param( 'start' ) || $request->has_param( 'end' );
791        $period = Period::resolve(
792            $custom
793                ? array(
794                    'start' => (string) $request->get_param( 'start' ),
795                    'end'   => (string) $request->get_param( 'end' ),
796                )
797                : array( 'range' => (int) $request->get_param( 'range' ) ),
798            self::MAX_STATS_DAYS
799        );
800        if ( is_wp_error( $period ) ) {
801            return $period;
802        }
803        // Counts go back KEEP_DAYS, so a custom range can't start before that.
804        if ( $custom && $period['start'] < Period::add_days( Period::local( time() )->format( 'Y-m-d' ), 1 - Stats::KEEP_DAYS ) ) {
805            /* translators: %s: how many days back the dashboard goes, e.g. 800. */
806            return new WP_Error( 'jetpack_send_bad_period', sprintf( __( 'Pick a start date in the last %s days.', 'jetpack-send-pkg' ), number_format_i18n( Stats::KEEP_DAYS ) ), array( 'status' => 400 ) );
807        }
808        return rest_ensure_response( self::stats_data( $period ) );
809    }
810
811    /**
812     * The stats payload. A preset range is cached until a new row is logged, the counts change under
813     * the same rows (a delete, the prune, a moved count, a test's result), or the site's hour changes.
814     *
815     * @param int|array $period   A preset range in days, or Period::resolve()'s result.
816     * @param int|null  $now      Unix time to count back from; defaults to now.
817     * @return array
818     */
819    public static function stats_data( $period, $now = null ) {
820        $now = null === $now ? time() : (int) $now;
821        if ( ! is_array( $period ) ) {
822            $period = Period::resolve( array( 'range' => in_array( (int) $period, Period::PRESETS, true ) ? (int) $period : 90 ), 0, $now );
823        }
824        $preset = in_array( $period['range'], Period::PRESETS, true ) ? (int) $period['range'] : null;
825        $moment = Period::local( $now );
826        $check  = array(
827            'gen'        => Email_Log::stats_gen(),
828            'max_id'     => Email_Log::max_id(),
829            'local_date' => $moment->format( 'Y-m-d' ),
830            'local_hour' => $moment->format( 'H' ),
831            // Zones like Kolkata and Kathmandu share a date and hour but not where the hour starts.
832            'offset'     => $moment->format( 'P' ),
833        );
834        $key    = Email_Log::STATS_TRANSIENT . $preset;
835        if ( null !== $preset ) {
836            $cached = get_transient( $key );
837            if ( is_array( $cached ) && isset( $cached['data'] ) && array_intersect_key( $cached, $check ) === $check ) {
838                return $cached['data'];
839            }
840        }
841
842        $statuses = Stats::STATUSES;
843        $series   = Stats::series( $period['start_utc'], $period['end_utc'] );
844        $totals   = array_fill_keys( $statuses, 0 );
845        $tests    = 0;
846        foreach ( Stats::totals_by_source( $period['start_utc'], $period['end_utc'] ) as $source => $counts ) {
847            foreach ( $statuses as $status ) {
848                if ( 'test' === $source ) {
849                    $tests += (int) ( $counts[ $status ] ?? 0 );
850                } else {
851                    $totals[ $status ] += (int) ( $counts[ $status ] ?? 0 );
852                }
853            }
854        }
855
856        $days  = array();
857        $hours = array();
858        if ( 'hour' === $period['unit'] ) {
859            for ( $hour = $period['start_utc']; $hour < $period['end_utc']; $hour += HOUR_IN_SECONDS ) {
860                $hours[] = array( 'start' => gmdate( 'Y-m-d\\TH:i:s\\Z', $hour ) ) + array_fill_keys( $statuses, 0 );
861            }
862            // Each count is the site-local hour it was written in. After a move to or from a half-hour zone
863            // those no longer start where these hours do, so each goes in the hour it starts in.
864            foreach ( $series as $bucket => $counts ) {
865                $slot = intdiv( (int) $bucket - $period['start_utc'], HOUR_IN_SECONDS );
866                if ( (int) $bucket < $period['start_utc'] || ! isset( $hours[ $slot ] ) ) {
867                    continue;
868                }
869                foreach ( $statuses as $status ) {
870                    $hours[ $slot ][ $status ] += (int) ( $counts[ $status ] ?? 0 );
871                }
872            }
873        } else {
874            $by_day = array();
875            foreach ( $series as $bucket => $counts ) {
876                $date = Period::local( $bucket )->format( 'Y-m-d' );
877                foreach ( $statuses as $status ) {
878                    $by_day[ $date ][ $status ] = ( $by_day[ $date ][ $status ] ?? 0 ) + $counts[ $status ];
879                }
880            }
881            // Step through dates, not times: where the clocks go forward at midnight, a day starts at 01:00.
882            $last = Period::local( $period['end_utc'] - 1 )->format( 'Y-m-d' );
883            for ( $date = Period::local( $period['start_utc'] )->format( 'Y-m-d' ); $date <= $last; $date = Period::add_days( $date, 1 ) ) {
884                $days[] = array( 'date' => $date ) + ( $by_day[ $date ] ?? array_fill_keys( $statuses, 0 ) );
885            }
886        }
887
888        $last_email = Email_Log::last_email_at();
889        $newest     = Stats::newest();
890        $since      = Stats::since();
891        $covers     = Email_Log::oldest_created_at();
892        $data       = array(
893            'range'           => $preset,
894            'from'            => $period['start'],
895            'to'              => $period['end'],
896            'unit'            => $period['unit'],
897            'totals'          => array( 'emails' => array_sum( $totals ) ) + $totals,
898            'tests'           => $tests,
899            'days'            => $days,
900            'hours'           => $hours,
901            'origins'         => self::top_origins( Stats::origins( $period['start_utc'], $period['end_utc'] ) ),
902            'attention'       => Email_Log::attention( $period['start_utc'], $period['end_utc'] ),
903            'last_email_at'   => null === $last_email ? null : Email_Log::iso( $last_email ),
904            'last_email_day'  => null === $newest ? null : Period::local( $newest )->format( 'Y-m-d' ),
905            'has_history'     => Stats::has_history(),
906            'stats_since'     => null === $since ? null : Period::local( $since )->format( 'Y-m-d' ),
907            'log_covers_from' => null === $covers ? null : Email_Log::iso( $covers ),
908        );
909
910        if ( null !== $preset ) {
911            set_transient( $key, $check + array( 'data' => $data ), 10 * MINUTE_IN_SECONDS );
912        }
913        return $data;
914    }
915
916    /**
917     * The five busiest origins with plain names and plugin icons, plus one row for the rest.
918     *
919     * @param array $rows `origin` and `n` per origin, most first.
920     * @return array<int, array{key: string, label: string, count: int, icon_url: string|null}>
921     */
922    private static function top_origins( array $rows ) {
923        $top  = array();
924        $rest = 0;
925        foreach ( array_values( $rows ) as $index => $row ) {
926            if ( $index < 5 ) {
927                $top[] = array(
928                    'key'      => (string) $row['origin'],
929                    'label'    => Email_Origin::label( (string) $row['origin'] ),
930                    'count'    => (int) $row['n'],
931                    'icon_url' => Email_Origin::icon_url( (string) $row['origin'] ),
932                );
933            } else {
934                $rest += (int) $row['n'];
935            }
936        }
937        if ( $rest > 0 ) {
938            $top[] = array(
939                'key'      => 'other',
940                'label'    => __( 'Everything else', 'jetpack-send-pkg' ),
941                'count'    => $rest,
942                'icon_url' => null,
943            );
944        }
945        return $top;
946    }
947
948    /**
949     * Now, or a given Unix time, in the site's timezone.
950     *
951     * @param int|null $now Unix time; defaults to now.
952     * @return \DateTimeImmutable
953     */
954    private static function site_moment( $now = null ) {
955        return ( new \DateTimeImmutable( '@' . ( null === $now ? time() : (int) $now ) ) )->setTimezone( wp_timezone() );
956    }
957
958    /**
959     * GET /log: a page of emails, filtered and searched.
960     *
961     * @param WP_REST_Request $request Request.
962     * @return \WP_REST_Response|WP_Error
963     */
964    public static function get_log( WP_REST_Request $request ) {
965        $args   = array(
966            'page'     => (int) $request->get_param( 'page' ),
967            'per_page' => (int) $request->get_param( 'per_page' ),
968            'search'   => (string) $request->get_param( 'search' ),
969            'status'   => (array) $request->get_param( 'status' ),
970            'origin'   => (array) $request->get_param( 'origin' ),
971            'source'   => (array) $request->get_param( 'source' ),
972        );
973        $window = self::log_window( $request );
974        if ( is_wp_error( $window ) ) {
975            return $window;
976        }
977        if ( null !== $window ) {
978            $args['start_utc'] = $window['start_utc'];
979            $args['end_utc']   = $window['end_utc'];
980        }
981
982        $page = Email_Log::query( $args );
983        foreach ( $page['items'] as $index => $item ) {
984            $page['items'][ $index ] = self::log_row( $item );
985        }
986        return rest_ensure_response( $page );
987    }
988
989    /**
990     * The dates GET /log filters by: custom days, a range, an older `days` link, or none.
991     *
992     * @param WP_REST_Request $request Request.
993     * @return array|WP_Error|null Period::resolve()'s result, or null for any date.
994     */
995    private static function log_window( WP_REST_Request $request ) {
996        if ( $request->has_param( 'start' ) || $request->has_param( 'end' ) ) {
997            return Period::resolve(
998                array(
999                    'start' => (string) $request->get_param( 'start' ),
1000                    'end'   => (string) $request->get_param( 'end' ),
1001                )
1002            );
1003        }
1004        if ( $request->has_param( 'range' ) ) {
1005            return Period::resolve( array( 'range' => (int) $request->get_param( 'range' ) ) );
1006        }
1007        $days = (int) $request->get_param( 'days' );
1008        return $days > 0 ? Period::resolve( array( 'days' => $days ) ) : null;
1009    }
1010
1011    /**
1012     * A log row as GET /log returns it: ids as strings, the origin's name and icon, and the first recipient's avatar.
1013     *
1014     * @param array $item Row from Email_Log::query().
1015     * @return array
1016     */
1017    private static function log_row( array $item ) {
1018        $origin = (string) ( $item['origin'] ?? '' );
1019        $item   = array_merge(
1020            $item,
1021            array(
1022                'origin'          => $origin,
1023                'origin_label'    => '' === $origin ? '' : Email_Origin::label( $origin ),
1024                'origin_icon_url' => '' === $origin ? null : Email_Origin::icon_url( $origin ),
1025                'from_email'      => (string) ( $item['from_email'] ?? '' ),
1026                'reason'          => (string) ( $item['reason'] ?? '' ),
1027                'resend_of'       => (int) ( $item['resend_of'] ?? 0 ) > 0 ? (string) $item['resend_of'] : '',
1028                'avatar_url'      => self::avatar_url( (string) ( $item['recipients'] ?? '' ) ),
1029            )
1030        );
1031        return $item;
1032    }
1033
1034    /**
1035     * GET /log/origins: what sent the emails still in the log, most first, for the Source filter.
1036     *
1037     * @return \WP_REST_Response
1038     */
1039    public static function get_log_origins() {
1040        $origins = array();
1041        foreach ( Email_Log::origins_in_log() as $row ) {
1042            $key       = (string) $row['origin'];
1043            $origins[] = array(
1044                'key'      => $key,
1045                'label'    => Email_Origin::label( $key ),
1046                'icon_url' => Email_Origin::icon_url( $key ),
1047                'count'    => (int) $row['n'],
1048            );
1049        }
1050        return rest_ensure_response( $origins );
1051    }
1052
1053    /**
1054     * GET /log/summary: how many emails the log holds, and how many each retention choice would delete.
1055     *
1056     * @return \WP_REST_Response
1057     */
1058    public static function get_log_summary() {
1059        return rest_ensure_response( Email_Log::summary() );
1060    }
1061
1062    /**
1063     * The first recipient's avatar, or null when the site hides avatars or there's no address.
1064     *
1065     * @param string $recipients The logged recipients, comma-separated, maybe with names.
1066     * @return string|null
1067     */
1068    public static function avatar_url( $recipients ) {
1069        $email = self::first_recipient( $recipients );
1070        return '' === $email ? null : self::avatar_for_email( $email );
1071    }
1072
1073    /**
1074     * One address's avatar through core, so avatar plugins and privacy filters still apply.
1075     * An address without one gets a transparent image, so the page's own initial shows through without an error.
1076     *
1077     * @param string $email Email address.
1078     * @return string|null Null when the site hides avatars.
1079     */
1080    public static function avatar_for_email( $email ) {
1081        if ( ! get_option( 'show_avatars' ) || '' === (string) $email ) {
1082            return null;
1083        }
1084        $url = get_avatar_url(
1085            (string) $email,
1086            array(
1087                'size'    => self::AVATAR_SIZE,
1088                'default' => 'blank',
1089            )
1090        );
1091        return is_string( $url ) && '' !== $url ? $url : null;
1092    }
1093
1094    /**
1095     * The first email address in a recipient list such as `"Desk, Help" <help@example.com>, sales@example.com`.
1096     *
1097     * @param string $recipients Recipients.
1098     * @return string The address, or '' when there's no valid one.
1099     */
1100    public static function first_recipient( $recipients ) {
1101        if ( ! preg_match( '/[^\s<>,;"\'()]+@[^\s<>,;"\'()]+/', (string) $recipients, $match ) ) {
1102            return '';
1103        }
1104        $email = is_email( $match[0] );
1105        return is_string( $email ) ? $email : '';
1106    }
1107
1108    /**
1109     * GET /log/{id}: one row with the content kept for previewing, why it failed, and what can be done.
1110     *
1111     * @param WP_REST_Request $request Request.
1112     * @return \WP_REST_Response|WP_Error
1113     */
1114    public static function get_log_entry( WP_REST_Request $request ) {
1115        $id    = (int) $request->get_param( 'id' );
1116        $entry = Email_Log::get( $id );
1117        if ( ! $entry ) {
1118            return self::entry_not_found();
1119        }
1120        $from                     = is_array( $entry['message'] ?? null ) && is_array( $entry['message']['from'] ?? null ) ? $entry['message']['from'] : null;
1121        $entry['avatar_url']      = self::avatar_url( (string) ( $entry['recipients'] ?? '' ) );
1122        $entry['from_avatar_url'] = $from ? self::avatar_for_email( (string) ( $from['email'] ?? '' ) ) : null;
1123        $origin                   = (string) ( $entry['origin'] ?? '' );
1124        $entry['origin_label']    = '' === $origin ? '' : Email_Origin::label( $origin );
1125        $entry['origin_icon_url'] = '' === $origin ? null : Email_Origin::icon_url( $origin );
1126
1127        $detail          = is_string( $entry['detail'] ?? null ) ? json_decode( $entry['detail'], true ) : null;
1128        $entry['reason'] = (string) ( $entry['reason'] ?? '' );
1129        $entry['detail'] = is_array( $detail ) ? $detail : null;
1130        $entry['resend'] = Resend::eligibility( $entry );
1131
1132        $resends                  = Email_Log::resends_of( $id );
1133        $entry['resends']         = array_map(
1134            fn( $row ) => array(
1135                'id'         => (string) $row['id'],
1136                'status'     => (string) $row['status'],
1137                'created_at' => (string) $row['created_at'],
1138            ),
1139            $resends['items']
1140        );
1141        $entry['resend_count']    = $resends['total'];
1142        $original                 = (int) ( $entry['resend_of'] ?? 0 );
1143        $original_row             = $original > 0 ? Email_Log::brief_of( $original ) : null;
1144        $entry['resend_of']       = $original > 0 ? (string) $original : '';
1145        $entry['resend_of_entry'] = $original > 0 ? array(
1146            'id'         => (string) $original,
1147            'exists'     => null !== $original_row,
1148            'created_at' => $original_row['created_at'] ?? null,
1149            'status'     => $original_row['status'] ?? null,
1150        ) : null;
1151        $entry['resent_by']       = 'resend' === ( $entry['source'] ?? '' ) ? Email_Log::user_name( (int) ( $entry['user_id'] ?? 0 ) ) : '';
1152        $entry['debug_count']     = Debug_Log::count( $id );
1153        $entry['support_text']    = self::support_text( $entry );
1154        // Search columns and who resent it by id stay on the server.
1155        unset( $entry['body_text'], $entry['other_addresses'], $entry['user_id'] );
1156        return rest_ensure_response( $entry );
1157    }
1158
1159    /**
1160     * A plain report of one email for support: domains only, never the subject, body, full addresses or a key.
1161     * English on purpose: support reads it.
1162     *
1163     * @param array $entry The entry, with `detail` decoded.
1164     * @return string
1165     */
1166    private static function support_text( array $entry ) {
1167        $detail   = is_array( $entry['detail'] ?? null ) ? $entry['detail'] : array();
1168        $message  = is_array( $entry['message'] ?? null ) ? $entry['message'] : array();
1169        $created  = Period::local( (int) strtotime( (string) $entry['created_at'] . ' UTC' ) );
1170        $domain   = fn( $email ) => strtolower( (string) substr( (string) strrchr( (string) $email, '@' ), 1 ) );
1171        $account  = Account::get();
1172        $from     = '' !== (string) ( $message['from']['email'] ?? '' ) ? $domain( $message['from']['email'] ) : ( $account ? (string) $account['domain'] : '' );
1173        $to       = isset( $message['to'] ) && is_array( $message['to'] ) ? array_column( $message['to'], 'email' ) : array_column( Message_Builder::parse_recipients( (string) ( $entry['recipients'] ?? '' ) ), 'email' );
1174        $words    = array_merge( (array) ( $detail['service_errors'] ?? array() ), isset( $detail['result']['reason'] ) ? array( $detail['result']['reason'] ) : array() );
1175        $host     = is_array( $detail['host'] ?? null ) ? $detail['host'] : null;
1176        $records  = is_array( $detail['records'] ?? null ) ? $detail['records'] : null;
1177        $or_dash  = fn( $value ) => '' === (string) $value ? '—' : (string) $value;
1178        $host_out = '—';
1179        if ( $host ) {
1180            $host_out = 'failed' === ( $host['result'] ?? '' ) ? 'failed (' . ( $host['reason'] ?? 'host_failed' ) . ')' : (string) ( $host['result'] ?? '' );
1181        }
1182
1183        $lines = array(
1184            'Jetpack Send email report',
1185            sprintf( 'When: %s (%s)', $created->format( 'Y-m-d H:i:s' ), Period::zone_label( $created ) ),
1186            'Status: ' . $entry['status'] . ( '' !== $entry['reason'] ? ' (' . $entry['reason'] . ')' : '' ),
1187            'From domain: ' . $or_dash( $from ),
1188            'To domain: ' . $or_dash( implode( ', ', array_unique( array_filter( array_map( $domain, $to ) ) ) ) ),
1189            'What happened: ' . $or_dash( Failure_Reason::what_happened( (string) $entry['status'], $detail ? $detail : null ) ),
1190            'The service said: ' . $or_dash( Debug_Log::mask_emails( implode( ' ', array_filter( array_map( 'strval', $words ) ) ) ) ),
1191            sprintf( 'Request ID: %s  Message ID: %s', $or_dash( $entry['request_id'] ?? '' ), $or_dash( $detail['message_id'] ?? ( $detail['result']['message_id'] ?? '' ) ) ),
1192            'Time taken: ' . ( isset( $detail['timing']['ms'] ) ? (int) $detail['timing']['ms'] . ' ms' : '—' ),
1193            sprintf( 'Backup mail: %s   Your host\'s mail: %s', $or_dash( $detail['fallback'] ?? '' ), $host_out ),
1194        );
1195        if ( $records ) {
1196            $lines[] = sprintf(
1197                'Records at the time: permission %s, signing %s, sender list %s%s',
1198                $or_dash( $records['lockdown'] ?? '' ),
1199                $or_dash( $records['dkim'] ?? '' ),
1200                $or_dash( $records['spf'] ?? '' ),
1201                empty( $records['checked_at'] ) ? '' : ' (checked ' . Period::local( (int) strtotime( (string) $records['checked_at'] ) )->format( 'Y-m-d H:i' ) . ')'
1202            );
1203        }
1204        $lines[] = sprintf( 'Versions: Send %s, WordPress %s, PHP %s', Send::PACKAGE_VERSION, get_bloginfo( 'version' ), PHP_VERSION );
1205        return implode( "\n", $lines );
1206    }
1207
1208    /**
1209     * POST /log/{id}/resend: send a copy of a logged email, to the same people, the current user or others.
1210     *
1211     * @param WP_REST_Request $request Request.
1212     * @return \WP_REST_Response|WP_Error
1213     */
1214    public static function resend_log_entry( WP_REST_Request $request ) {
1215        $id    = (int) $request->get_param( 'id' );
1216        $entry = Email_Log::get( $id );
1217        if ( ! $entry ) {
1218            return self::entry_not_found();
1219        }
1220
1221        $eligibility = Resend::eligibility( $entry );
1222        if ( ! $eligibility['allowed'] ) {
1223            return self::resend_refused(
1224                $id,
1225                new WP_Error(
1226                    'jetpack_send_not_resendable',
1227                    __( 'This email can’t be sent again.', 'jetpack-send-pkg' ),
1228                    array(
1229                        'status' => 403,
1230                        'reason' => $eligibility['reason'],
1231                    )
1232                )
1233            );
1234        }
1235        $user  = wp_get_current_user();
1236        $lists = Resend::validate( $entry, (array) $request->get_param( 'to' ), (array) $request->get_param( 'cc' ), (array) $request->get_param( 'bcc' ), (string) $user->user_email );
1237        if ( is_wp_error( $lists ) ) {
1238            return self::resend_refused( $id, $lists );
1239        }
1240
1241        // One send at a time per email, so a double click can't send two copies.
1242        if ( ! Lock::acquire( 'resend_' . $id, MINUTE_IN_SECONDS ) ) {
1243            return self::resend_refused( $id, new WP_Error( 'jetpack_send_resend_in_flight', __( 'This email is already being sent.', 'jetpack-send-pkg' ), array( 'status' => 409 ) ) );
1244        }
1245        try {
1246            if ( ! Resend::claim() ) {
1247                $minutes = max( 1, (int) ceil( Resend::retry_after() / MINUTE_IN_SECONDS ) );
1248                $limit   = Resend::limit();
1249                return self::resend_refused(
1250                    $id,
1251                    new WP_Error(
1252                        'jetpack_send_resend_rate_limited',
1253                        sprintf(
1254                            /* translators: 1: how many emails were resent in the last hour, 2: the sentence saying how long to wait. */
1255                            __( '%1$s %2$s', 'jetpack-send-pkg' ),
1256                            sprintf(
1257                                /* translators: %d: how many emails can be resent an hour. */
1258                                _n( 'You’ve resent %d email in the last hour.', 'You’ve resent %d emails in the last hour.', $limit, 'jetpack-send-pkg' ),
1259                                $limit
1260                            ),
1261                            sprintf(
1262                                /* translators: %d: minutes to wait. */
1263                                _n( 'Try again in %d minute.', 'Try again in %d minutes.', $minutes, 'jetpack-send-pkg' ),
1264                                $minutes
1265                            )
1266                        ),
1267                        array(
1268                            'status'      => 429,
1269                            'retry_after' => Resend::retry_after(),
1270                        )
1271                    )
1272                );
1273            }
1274            $result = Resend::send( $entry, $lists['to'], $lists['cc'], $lists['bcc'], (int) $user->ID );
1275            if ( is_wp_error( $result ) ) {
1276                return self::resend_refused( $id, $result );
1277            }
1278            return rest_ensure_response( array( 'success' => Email_Log::STATUS_SENT === $result['status'] ) + $result );
1279        } finally {
1280            Lock::release( 'resend_' . $id );
1281        }
1282    }
1283
1284    /**
1285     * Note a resend that never went out, and pass its error on.
1286     *
1287     * @param int      $id    The original's row id.
1288     * @param WP_Error $error Why.
1289     * @return WP_Error
1290     */
1291    private static function resend_refused( $id, WP_Error $error ) {
1292        $data   = $error->get_error_data();
1293        $reason = is_array( $data ) && ! empty( $data['reason'] ) ? (string) $data['reason'] : (string) $error->get_error_code();
1294        Debug_Log::add(
1295            'warning',
1296            'sending',
1297            'resend.refused',
1298            /* translators: %s: why, e.g. jetpack_send_resend_rate_limited. */
1299            sprintf( __( 'Resend refused: %s.', 'jetpack-send-pkg' ), $reason ),
1300            array(
1301                'reason' => $reason,
1302                'code'   => $error->get_error_code(),
1303            ),
1304            $id
1305        );
1306        return $error;
1307    }
1308
1309    /**
1310     * DELETE /log/{id}: remove one email from the log for good.
1311     *
1312     * @param WP_REST_Request $request Request.
1313     * @return \WP_REST_Response|WP_Error
1314     */
1315    public static function delete_log_entry( WP_REST_Request $request ) {
1316        $result = Email_Log::delete( array( (int) $request->get_param( 'id' ) ) );
1317        if ( array() === $result['deleted'] ) {
1318            return self::entry_not_found();
1319        }
1320        return rest_ensure_response( array( 'deleted' => array_map( 'strval', $result['deleted'] ) ) );
1321    }
1322
1323    /**
1324     * POST /log/delete: remove up to 100 emails at once. Ids already gone come back as `missing`, so a stale selection still works.
1325     *
1326     * @param WP_REST_Request $request Request.
1327     * @return \WP_REST_Response
1328     */
1329    public static function delete_log_entries( WP_REST_Request $request ) {
1330        $result = Email_Log::delete( (array) $request->get_param( 'ids' ) );
1331        return rest_ensure_response(
1332            array(
1333                'deleted' => array_map( 'strval', $result['deleted'] ),
1334                'missing' => array_map( 'strval', $result['missing'] ),
1335            )
1336        );
1337    }
1338
1339    /**
1340     * The error for a log row that isn't there.
1341     *
1342     * @return WP_Error
1343     */
1344    private static function entry_not_found() {
1345        return new WP_Error( 'jetpack_send_log_entry_not_found', __( 'This email is no longer in the log.', 'jetpack-send-pkg' ), array( 'status' => 404 ) );
1346    }
1347
1348    /**
1349     * POST /reset: clears local state even if deprovisioning fails, so the site is never stuck.
1350     *
1351     * @return \WP_REST_Response
1352     */
1353    public static function reset() {
1354        $account     = Account::get();
1355        $provisioner = Provisioner_Factory::get();
1356        $warning     = '';
1357        if ( $account && $provisioner && $provisioner->get_type() === $account['provider'] ) {
1358            $result = $provisioner->deprovision( $account );
1359            if ( is_wp_error( $result ) ) {
1360                $warning = $result->get_error_message();
1361            }
1362        }
1363
1364        Account::delete();
1365        Settings::update( array( 'enabled' => false ) );
1366        Dns_Watch::stop();
1367        Onboarding::delete();
1368        delete_transient( self::CHECK_DNS_THROTTLE );
1369        // The log, its counts and the debug log stay: they're the site's history, not the account's.
1370        Email_Log::clear_stats_cache();
1371        Debug_Log::add( 'warning', 'settings', 'sending.reset', __( 'Started over: the sending account was removed.', 'jetpack-send-pkg' ) );
1372
1373        $data                  = self::status_data();
1374        $data['reset_warning'] = $warning;
1375        return rest_ensure_response( $data );
1376    }
1377
1378    /**
1379     * Normalize user input to a bare lowercase domain, or '' when invalid.
1380     *
1381     * @param string $domain Input.
1382     * @return string
1383     */
1384    public static function sanitize_domain( $domain ) {
1385        $domain = strtolower( trim( $domain ) );
1386        $domain = (string) preg_replace( '#^[a-z]+://#', '', $domain );
1387        $domain = rtrim( explode( '/', $domain )[0], '.' );
1388        return preg_match( '/^(?=.{4,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/', $domain ) ? $domain : '';
1389    }
1390
1391    /**
1392     * The "not set up" error.
1393     *
1394     * @return WP_Error
1395     */
1396    private static function not_set_up() {
1397        return new WP_Error( 'jetpack_send_not_set_up', __( 'Set up Send first.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
1398    }
1399
1400    /**
1401     * Give an upstream error a valid REST status.
1402     *
1403     * @param WP_Error $error Error.
1404     * @return WP_Error
1405     */
1406    private static function error_response( WP_Error $error ) {
1407        $data   = $error->get_error_data();
1408        $status = is_array( $data ) && isset( $data['status'] ) ? (int) $data['status'] : 0;
1409        return new WP_Error( $error->get_error_code(), $error->get_error_message(), array( 'status' => $status >= 400 ? $status : 502 ) );
1410    }
1411}