Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
83.25% covered (warning)
83.25%
174 / 209
45.00% covered (danger)
45.00%
9 / 20
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dns_Watch
84.06% covered (warning)
84.06%
174 / 207
45.00% covered (danger)
45.00%
9 / 20
124.55
0.00% covered (danger)
0.00%
0 / 1
 init
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 start
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
1
 stop
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 unschedule
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 ensure_scheduled
62.50% covered (warning)
62.50%
5 / 8
0.00% covered (danger)
0.00%
0 / 1
4.84
 run
100.00% covered (success)
100.00%
32 / 32
100.00% covered (success)
100.00%
1 / 1
13
 note_run
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
3
 next_delay
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
4
 next_check_at
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 progress
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
4
 lock
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 passes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 check_with_mail_service
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
4.03
 existing_spf
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
10.50
 finish
91.67% covered (success)
91.67%
11 / 12
0.00% covered (danger)
0.00%
0 / 1
8.04
 can_receive_test
75.00% covered (warning)
75.00%
3 / 4
0.00% covered (danger)
0.00%
0 / 1
3.14
 send_stall_email
92.86% covered (success)
92.86%
39 / 42
0.00% covered (danger)
0.00%
0 / 1
13.06
 missing_lines
76.47% covered (warning)
76.47%
13 / 17
0.00% covered (danger)
0.00%
0 / 1
6.47
 fix_line
18.18% covered (danger)
18.18%
2 / 11
0.00% covered (danger)
0.00%
0 / 1
33.84
 record_label
57.14% covered (warning)
57.14%
4 / 7
0.00% covered (danger)
0.00%
0 / 1
6.97
1<?php
2/**
3 * Background check that keeps watching the domain's records after the user leaves.
4 *
5 * @package automattic/jetpack-send
6 */
7
8namespace Automattic\Jetpack\Send;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14/**
15 * Cron watch: checks the records on a backing-off schedule for up to 7 days, turns sending on
16 * when they pass and sends one automatic test.
17 */
18class Dns_Watch {
19
20    /**
21     * Cron hook.
22     *
23     * @var string
24     */
25    const HOOK = 'jetpack_send_dns_watch';
26
27    /**
28     * Option holding the watch's progress: `{started_at, runs, stalled_notified_at}` (Unix times).
29     *
30     * @var string
31     */
32    const OPTION = 'jetpack_send_dns_watch';
33
34    /**
35     * How long the watch runs before it gives up, in seconds.
36     *
37     * @var int
38     */
39    const MAX_AGE = 7 * DAY_IN_SECONDS;
40
41    /**
42     * How long the records can stay unverified before the one reminder email goes out.
43     *
44     * @var int
45     */
46    const STALL_AFTER = 2 * DAY_IN_SECONDS;
47
48    /**
49     * Without every record found locally, ask the mail service only on every Nth run.
50     *
51     * @var int
52     */
53    const SERVICE_CHECK_EVERY = 5;
54
55    /**
56     * Option used as a lock so overlapping cron runs can't send two tests.
57     *
58     * @var string
59     */
60    const LOCK_OPTION = 'jetpack_send_dns_watch_lock';
61
62    /**
63     * Seconds after which a lock left by a crashed run is ignored.
64     *
65     * @var int
66     */
67    const LOCK_TTL = 10 * MINUTE_IN_SECONDS;
68
69    /**
70     * Hook the cron callback.
71     *
72     * @return void
73     */
74    public static function init() {
75        add_action( self::HOOK, array( __CLASS__, 'run' ) );
76        add_action( 'admin_init', array( __CLASS__, 'ensure_scheduled' ) );
77    }
78
79    /**
80     * Start (or restart) watching the stored account's domain. Called after setup.
81     *
82     * @return void
83     */
84    public static function start() {
85        self::unschedule();
86        update_option(
87            self::OPTION,
88            array(
89                'started_at'          => time(),
90                'runs'                => 0,
91                'stalled_notified_at' => null,
92            ),
93            false
94        );
95        wp_schedule_single_event( time() + (int) self::next_delay( 0 ), self::HOOK );
96    }
97
98    /**
99     * Stop watching and clear the schedule. Called on reset, uninstall and once sending is on.
100     *
101     * @return void
102     */
103    public static function stop() {
104        self::unschedule();
105        delete_option( self::OPTION );
106        delete_option( self::LOCK_OPTION );
107    }
108
109    /**
110     * Clear the scheduled run but keep the progress, so the watch can pick up again (plugin deactivation).
111     *
112     * @return void
113     */
114    public static function unschedule() {
115        wp_clear_scheduled_hook( self::HOOK );
116    }
117
118    /**
119     * Put a lost or paused schedule back while the watch is still running.
120     *
121     * @return void
122     */
123    public static function ensure_scheduled() {
124        $watch = self::progress();
125        if ( ! $watch || wp_next_scheduled( self::HOOK ) ) {
126            return;
127        }
128        $delay = self::next_delay( time() - $watch['started_at'] );
129        if ( null === $delay ) {
130            self::stop();
131            return;
132        }
133        wp_schedule_single_event( time() + $delay, self::HOOK );
134    }
135
136    /**
137     * One cron run: local lookup first, the mail-service check only when every record is found
138     * or every 5th run; on pass, Onboarding::maybe_auto_enable() and the automatic test.
139     *
140     * @return void
141     */
142    public static function run() {
143        $watch   = self::progress();
144        $account = Account::get();
145        if ( ! $watch || ! $account ) {
146            self::stop();
147            return;
148        }
149        if ( ! self::lock() ) {
150            return;
151        }
152
153        try {
154            ++$watch['runs'];
155            update_option( self::OPTION, $watch, false );
156
157            $elapsed = time() - $watch['started_at'];
158            if ( self::passes( $account ) ) {
159                self::note_run( $watch['runs'], 'passed', $elapsed );
160                self::finish();
161                return;
162            }
163
164            $lookup = Dns_Lookup::check( $account );
165            if ( ! empty( $lookup['all_found'] ) || 0 === $watch['runs'] % self::SERVICE_CHECK_EVERY ) {
166                $account = self::check_with_mail_service( $account );
167                if ( self::passes( $account ) ) {
168                    self::note_run( $watch['runs'], 'passed', $elapsed );
169                    self::finish();
170                    return;
171                }
172            }
173
174            if ( $elapsed >= self::STALL_AFTER && empty( $watch['stalled_notified_at'] ) ) {
175                $watch['stalled_notified_at'] = time();
176                update_option( self::OPTION, $watch, false );
177                self::send_stall_email( $account, $lookup, $watch['started_at'] );
178            }
179
180            $delay = self::next_delay( $elapsed );
181            self::note_run( $watch['runs'], null === $delay ? 'gave_up' : ( empty( $lookup['all_found'] ) ? 'waiting' : 'found' ), $elapsed, $delay );
182            if ( null === $delay ) {
183                self::stop();
184                return;
185            }
186            wp_schedule_single_event( time() + $delay, self::HOOK );
187        } finally {
188            delete_option( self::LOCK_OPTION );
189        }
190    }
191
192    /**
193     * Note one background check in the debug log.
194     *
195     * @param int      $run     Which run it was.
196     * @param string   $outcome `passed`, `found`, `waiting` or `gave_up`.
197     * @param int      $elapsed Seconds since the watch started.
198     * @param int|null $next    Seconds until the next run, or null for none.
199     * @return void
200     */
201    private static function note_run( $run, $outcome, $elapsed, $next = null ) {
202        Debug_Log::add(
203            'gave_up' === $outcome ? 'warning' : ( 'passed' === $outcome ? 'info' : 'debug' ),
204            'domain',
205            'dns.watch',
206            /* translators: 1: which background check it was, 2: what it found, e.g. waiting. */
207            sprintf( __( 'Background check %1$d: %2$s.', 'jetpack-send-pkg' ), $run, $outcome ),
208            array(
209                'run'     => (int) $run,
210                'outcome' => $outcome,
211                'elapsed' => (int) $elapsed,
212                'next'    => $next,
213            )
214        );
215    }
216
217    /**
218     * Seconds until the next run: 15 minutes for 2 hours, hourly to 48 hours, then daily.
219     *
220     * @param int $elapsed Seconds since the watch started.
221     * @return int|null Delay, or null once MAX_AGE has passed.
222     */
223    public static function next_delay( $elapsed ) {
224        $elapsed   = max( 0, (int) $elapsed );
225        $remaining = self::MAX_AGE - $elapsed;
226        if ( $remaining <= 0 ) {
227            return null;
228        }
229
230        if ( $elapsed < 2 * HOUR_IN_SECONDS ) {
231            $delay = 15 * MINUTE_IN_SECONDS;
232        } elseif ( $elapsed < self::STALL_AFTER ) {
233            $delay = HOUR_IN_SECONDS;
234        } else {
235            $delay = DAY_IN_SECONDS;
236        }
237        // The last run lands on day 7 rather than after it.
238        return min( $delay, $remaining );
239    }
240
241    /**
242     * When the next run is scheduled, for the status payload.
243     *
244     * @return string|null ISO 8601 time, or null when not watching.
245     */
246    public static function next_check_at() {
247        if ( ! self::progress() ) {
248            return null;
249        }
250        $next = wp_next_scheduled( self::HOOK );
251        return $next ? gmdate( 'c', (int) $next ) : null;
252    }
253
254    /**
255     * The stored progress, or null when not watching.
256     *
257     * @return array{started_at: int, runs: int, stalled_notified_at: int|null}|null
258     */
259    private static function progress() {
260        $watch = get_option( self::OPTION );
261        if ( ! is_array( $watch ) || empty( $watch['started_at'] ) ) {
262            return null;
263        }
264        return array(
265            'started_at'          => (int) $watch['started_at'],
266            'runs'                => (int) ( $watch['runs'] ?? 0 ),
267            'stalled_notified_at' => empty( $watch['stalled_notified_at'] ) ? null : (int) $watch['stalled_notified_at'],
268        );
269    }
270
271    /**
272     * Take the run lock; false when another run holds a fresh one.
273     *
274     * @return bool
275     */
276    private static function lock() {
277        if ( add_option( self::LOCK_OPTION, time(), '', false ) ) {
278            return true;
279        }
280        if ( time() - (int) get_option( self::LOCK_OPTION ) < self::LOCK_TTL ) {
281            return false;
282        }
283        update_option( self::LOCK_OPTION, time(), false );
284        return true;
285    }
286
287    /**
288     * Whether the account's last mail-service check passes every required record.
289     *
290     * @param array $account Stored account.
291     * @return bool
292     */
293    private static function passes( array $account ) {
294        return array() === REST_Controller::enable_blockers( $account );
295    }
296
297    /**
298     * Ask the mail service to check the domain and store its verdicts.
299     *
300     * @param array $account Stored account.
301     * @return array The account, updated when the check answered.
302     */
303    private static function check_with_mail_service( array $account ) {
304        $response = ( new MailChannels_Client( $account['api_key'] ) )->check_domain( $account['domain'], (string) ( $account['dkim_selector'] ?? '' ) );
305        if ( is_wp_error( $response ) || ! is_array( $response ) ) {
306            return $account;
307        }
308
309        $check = Dns_Check::normalize( $response, self::existing_spf( $account ) );
310        Account::update( array( 'dns_check' => $check ) );
311        REST_Controller::note_dns_check( (string) $account['domain'], $check );
312        $updated = Account::get();
313        return $updated ? $updated : $account;
314    }
315
316    /**
317     * The domain's SPF record, looked up through Dns_Lookup; the last known one when the lookup fails.
318     *
319     * @param array $account Stored account.
320     * @return string
321     */
322    private static function existing_spf( array $account ) {
323        $values = Dns_Lookup::txt_values( (string) $account['domain'] );
324        if ( false === $values ) {
325            $check = $account['dns_check'] ?? null;
326            return (string) ( is_array( $check ) && isset( $check['existing_spf'] ) ? $check['existing_spf'] : ( $account['existing_spf'] ?? '' ) );
327        }
328        foreach ( $values as $txt ) {
329            if ( 0 === stripos( $txt, 'v=spf1' ) ) {
330                return $txt;
331            }
332        }
333        return '';
334    }
335
336    /**
337     * The records passed: turn sending on, send the one automatic test, stop watching.
338     *
339     * @return void
340     */
341    private static function finish() {
342        Onboarding::maybe_auto_enable();
343
344        $settings = Settings::get();
345        $state    = Onboarding::state();
346        // Someone checked from the Send screen in the last minute, so they see it's ready and send their own test.
347        $watching = false !== get_transient( REST_Controller::CHECK_DNS_THROTTLE );
348        if ( ! $watching && ! empty( $settings['enabled'] ) && empty( $state['test_sent_at'] ) && empty( $state['auto_test_log_id'] ) ) {
349            $to = (string) ( $state['test_to'] ?? '' );
350            if ( self::can_receive_test( $to ) ) {
351                $result = Mailer::send_test( $to, 'auto' );
352                $log_id = is_wp_error( $result ) ? ( $result->get_error_data()['log_id'] ?? '' ) : ( $result['log_id'] ?? '' );
353                if ( '' !== (string) $log_id ) {
354                    Onboarding::record( array( 'auto_test_log_id' => (string) $log_id ) );
355                }
356            }
357        }
358
359        self::stop();
360    }
361
362    /**
363     * Whether the address belongs to a user who can still manage Send.
364     *
365     * @param string $email Address captured at setup.
366     * @return bool
367     */
368    private static function can_receive_test( $email ) {
369        if ( ! is_email( $email ) ) {
370            return false;
371        }
372        $user = get_user_by( 'email', $email );
373        return $user && user_can( $user, 'manage_options' );
374    }
375
376    /**
377     * Email the person who ran setup once, through the host's mail, when the records still aren't there.
378     *
379     * @param array $account    Stored account.
380     * @param array $lookup     Local lookup, for naming what's missing.
381     * @param int   $started_at When the watch started.
382     * @return void
383     */
384    private static function send_stall_email( array $account, $lookup, $started_at ) {
385        $state = Onboarding::state();
386        $to    = (string) ( $state['test_to'] ?? '' );
387        if ( ! is_email( $to ) ) {
388            $to = (string) get_option( 'admin_email' );
389        }
390        if ( ! is_email( $to ) ) {
391            return;
392        }
393
394        $user            = get_user_by( 'email', $to );
395        $switched_locale = $user && function_exists( 'switch_to_user_locale' ) && switch_to_user_locale( $user->ID );
396
397        $domain = (string) $account['domain'];
398        /* translators: %s: the domain email is sent from, e.g. example.com. */
399        $subject = sprintf( __( '%s isn’t connected to Jetpack Send yet', 'jetpack-send-pkg' ), $domain );
400
401        $all_found = is_array( $lookup ) && ! empty( $lookup['all_found'] );
402        $lines     = array(
403            $all_found
404                /* translators: %s: the domain email is sent from, e.g. example.com. */
405                ? sprintf( __( 'We can see both records for %s. Jetpack Send is still waiting for the mail service to confirm them. We’ve been checking for two days.', 'jetpack-send-pkg' ), $domain )
406                /* translators: %s: the domain email is sent from, e.g. example.com. */
407                : sprintf( __( 'Jetpack Send is still waiting for the records for %s. We’ve been checking for two days.', 'jetpack-send-pkg' ), $domain ),
408        );
409        $missing   = self::missing_lines( $lookup, $domain );
410        if ( $missing ) {
411            $lines[] = '';
412            $lines[] = __( 'Still to do:', 'jetpack-send-pkg' );
413            foreach ( $missing as $line ) {
414                $lines[] = '- ' . $line;
415            }
416        }
417        if ( ! $all_found ) {
418            $lines[] = '';
419            $lines[] = __( 'Check both records are saved where your domain is managed. Some providers take up to a day.', 'jetpack-send-pkg' );
420        }
421        $lines[] = '';
422        /* translators: %s: link to the Jetpack Send screen in wp-admin. */
423        $lines[] = sprintf( __( 'See the records and what to change: %s', 'jetpack-send-pkg' ), add_query_arg( 'welcome', '1', Admin_Page::dashboard_url() ) );
424        $lines[] = '';
425        $lines[] = sprintf(
426            /* translators: %s: date the automatic checks stop, e.g. October 25, 2026. */
427            __( 'We’ll keep checking until %s. Until then, your site’s emails go out through your host as usual.', 'jetpack-send-pkg' ),
428            wp_date( (string) get_option( 'date_format', 'F j, Y' ), $started_at + self::MAX_AGE )
429        );
430
431        // Send isn't sending for this site yet, so the host delivers this and it stays out of Send's log.
432        $priority = has_filter( 'pre_wp_mail', array( Mailer::class, 'pre_wp_mail' ) );
433        if ( false !== $priority ) {
434            remove_filter( 'pre_wp_mail', array( Mailer::class, 'pre_wp_mail' ), $priority );
435        }
436        try {
437            wp_mail( $to, $subject, implode( "\n", $lines ) );
438        } finally {
439            if ( false !== $priority ) {
440                add_filter( 'pre_wp_mail', array( Mailer::class, 'pre_wp_mail' ), $priority, 2 );
441            }
442            if ( $switched_locale ) {
443                restore_previous_locale();
444            }
445        }
446    }
447
448    /**
449     * One plain line for each record that isn't there yet.
450     *
451     * @param array  $lookup Local lookup.
452     * @param string $domain Sending domain.
453     * @return string[]
454     */
455    private static function missing_lines( $lookup, $domain ) {
456        $lines = array();
457        foreach ( (array) ( $lookup['records'] ?? array() ) as $record ) {
458            $state = (string) ( $record['state'] ?? '' );
459            if ( in_array( $state, array( 'found', 'verified' ), true ) ) {
460                continue;
461            }
462            $purpose = (string) ( $record['purpose'] ?? '' );
463            if ( 'sender_domain' === $purpose ) {
464                /* translators: %s: the domain email is sent from, e.g. example.com. */
465                $lines[] = sprintf( __( '%s isn’t pointing at a website or mailbox yet.', 'jetpack-send-pkg' ), $domain );
466                continue;
467            }
468            $label = self::record_label( $purpose, $domain );
469            if ( '' === $label ) {
470                continue;
471            }
472            $lines[] = 'needs_fix' === $state
473                ? self::fix_line( $label, (string) ( $record['problem'] ?? '' ) )
474                /* translators: %s: what the record does, e.g. "Signs your emails so inboxes trust them". */
475                : sprintf( __( '%s: not found yet', 'jetpack-send-pkg' ), $label );
476        }
477        return $lines;
478    }
479
480    /**
481     * One plain line naming what's wrong with a record, in the setup screen's words.
482     *
483     * @param string $label   What the record does.
484     * @param string $problem Problem code from the lookup.
485     * @return string
486     */
487    private static function fix_line( $label, $problem ) {
488        switch ( $problem ) {
489            case 'wrong_value':
490                /* translators: %s: what the record does, e.g. "Signs your emails so inboxes trust them". */
491                return sprintf( __( '%s: doesn’t match', 'jetpack-send-pkg' ), $label );
492            case 'doubled':
493                /* translators: %s: what the record does, e.g. "Signs your emails so inboxes trust them". */
494                return sprintf( __( '%s: name doubled', 'jetpack-send-pkg' ), $label );
495            case 'conflict':
496                /* translators: %s: what the record does, e.g. "Signs your emails so inboxes trust them". */
497                return sprintf( __( '%s: old record in the way', 'jetpack-send-pkg' ), $label );
498            case 'proxied':
499                /* translators: %s: what the record does, e.g. "Signs your emails so inboxes trust them". */
500                return sprintf( __( '%s: proxy on', 'jetpack-send-pkg' ), $label );
501            case 'not_live':
502                /* translators: %s: what the record does, e.g. "Signs your emails so inboxes trust them". */
503                return sprintf( __( '%s: not live yet', 'jetpack-send-pkg' ), $label );
504            default:
505                /* translators: %s: what the record does, e.g. "Signs your emails so inboxes trust them". */
506                return sprintf( __( '%s: needs a change', 'jetpack-send-pkg' ), $label );
507        }
508    }
509
510    /**
511     * The plain name of a record, as the setup screens show it.
512     *
513     * @param string $purpose `lockdown`, `dkim` or `spf`.
514     * @param string $domain  Sending domain.
515     * @return string
516     */
517    private static function record_label( $purpose, $domain ) {
518        switch ( $purpose ) {
519            case 'lockdown':
520                /* translators: %s: the domain email is sent from, e.g. example.com. */
521                return sprintf( __( 'Lets Jetpack send for %s', 'jetpack-send-pkg' ), $domain );
522            case 'dkim':
523                return __( 'Signs your emails so inboxes trust them', 'jetpack-send-pkg' );
524            case 'spf':
525                return __( 'Lists Jetpack as an approved sender', 'jetpack-send-pkg' );
526            default:
527                return '';
528        }
529    }
530}