Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
90.38% covered (success)
90.38%
47 / 52
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Dns_Check
94.00% covered (success)
94.00%
47 / 50
60.00% covered (warning)
60.00%
3 / 5
23.11
0.00% covered (danger)
0.00%
0 / 1
 normalize
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
3
 existing_spf
75.00% covered (warning)
75.00%
6 / 8
0.00% covered (danger)
0.00%
0 / 1
5.39
 spf_suggestion
100.00% covered (success)
100.00%
17 / 17
100.00% covered (success)
100.00%
1 / 1
4
 spf_lookups
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
3
 verdict_of
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
1<?php
2/**
3 * Reduces MailChannels check-domain results to per-record verdicts.
4 *
5 * @package automattic/jetpack-send
6 */
7
8namespace Automattic\Jetpack\Send;
9
10if ( ! defined( 'ABSPATH' ) ) {
11    exit( 0 );
12}
13
14/**
15 * DNS check helpers.
16 */
17class Dns_Check {
18
19    /**
20     * The include mechanism MailChannels requires in the domain's SPF record.
21     *
22     * @var string
23     */
24    const RELAY_INCLUDE = 'include:relay.mailchannels.net';
25
26    /**
27     * Normalize a check-domain response.
28     *
29     * @param array  $response     MailChannels response.
30     * @param string $existing_spf The domain's current SPF record, or ''.
31     * @return array{checked_at: string, domain_lockdown: string, spf: string, dkim: string, sender_domain: string, existing_spf: string}
32     */
33    public static function normalize( array $response, $existing_spf ) {
34        $results = isset( $response['check_results'] ) && is_array( $response['check_results'] ) ? $response['check_results'] : array();
35
36        return array(
37            'checked_at'      => gmdate( 'c' ),
38            'domain_lockdown' => self::verdict_of( $results['domain_lockdown'] ?? null ),
39            'spf'             => self::verdict_of( $results['spf'] ?? null ),
40            'dkim'            => self::verdict_of( $results['dkim'] ?? null ),
41            'sender_domain'   => self::verdict_of( $results['sender_domain'] ?? null ),
42            'existing_spf'    => (string) $existing_spf,
43        );
44    }
45
46    /**
47     * The domain's current SPF record, or '' when there is none or DNS lookups are unavailable.
48     *
49     * @param string $domain Domain.
50     * @return string
51     */
52    public static function existing_spf( $domain ) {
53        /**
54         * Short-circuits the DNS lookup for a domain's SPF record.
55         *
56         * @since $$next-version$$
57         *
58         * @param string|null $pre    The record, '' for none, or null to look it up.
59         * @param string      $domain Domain.
60         */
61        $pre = apply_filters( 'jetpack_send_pre_existing_spf', null, $domain );
62        if ( null !== $pre ) {
63            return (string) $pre;
64        }
65        // Every lookup goes through Dns_Lookup, so tests and local mocks can fake it.
66        $values = Dns_Lookup::txt_values( (string) $domain );
67        foreach ( is_array( $values ) ? $values : array() as $txt ) {
68            if ( 0 === stripos( $txt, 'v=spf1' ) ) {
69                return $txt;
70            }
71        }
72        return '';
73    }
74
75    /**
76     * The one SPF record to publish: a domain may only have one, so merge into any existing record.
77     *
78     * @param string $existing The domain's current SPF record, or ''.
79     * @return array{value: string, state: string} State is `new`, `merge` or `present`.
80     */
81    public static function spf_suggestion( $existing ) {
82        $existing = trim( (string) $existing );
83        if ( '' === $existing ) {
84            return array(
85                'value' => 'v=spf1 ' . self::RELAY_INCLUDE . ' ~all',
86                'state' => 'new',
87            );
88        }
89        if ( false !== stripos( $existing, self::RELAY_INCLUDE ) ) {
90            return array(
91                'value' => $existing,
92                'state' => 'present',
93            );
94        }
95
96        // Insert before the `all` mechanism wherever it sits: anything after it is never evaluated.
97        $all    = '/\s([~?+-]?all)(?=\s|$)/i';
98        $merged = preg_match( $all, $existing ) ? preg_replace( $all, ' ' . self::RELAY_INCLUDE . ' $1', $existing, 1 ) : $existing . ' ' . self::RELAY_INCLUDE;
99        return array(
100            'value' => (string) $merged,
101            'state' => 'merge',
102        );
103    }
104
105    /**
106     * How many DNS lookups an SPF record's own terms cost. Nested includes aren't followed.
107     *
108     * @param string $record SPF record.
109     * @return int
110     */
111    public static function spf_lookups( $record ) {
112        $count = 0;
113        foreach ( preg_split( '/\s+/', strtolower( trim( (string) $record ) ) ) as $term ) {
114            $term = ltrim( $term, '+-~?' );
115            if ( preg_match( '/^(include:|exists:|redirect=)|^(a|mx|ptr)(:|\/|$)/', $term ) ) {
116                ++$count;
117            }
118        }
119        return $count;
120    }
121
122    /**
123     * A node's verdict; for a group of checks, passed when any member passed.
124     *
125     * @param mixed $node A check result, or a list/map of them.
126     * @return string
127     */
128    private static function verdict_of( $node ) {
129        if ( ! is_array( $node ) || array() === $node ) {
130            return 'unknown';
131        }
132        if ( isset( $node['verdict'] ) && is_string( $node['verdict'] ) ) {
133            return strtolower( $node['verdict'] );
134        }
135
136        $verdicts = array();
137        foreach ( $node as $child ) {
138            $verdicts[] = self::verdict_of( $child );
139        }
140        if ( in_array( 'passed', $verdicts, true ) ) {
141            return 'passed';
142        }
143        return in_array( 'failed', $verdicts, true ) ? 'failed' : 'unknown';
144    }
145}