Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.78% covered (warning)
77.78%
77 / 99
40.00% covered (danger)
40.00%
4 / 10
CRAP
0.00% covered (danger)
0.00%
0 / 1
Local_Provisioner
79.38% covered (warning)
79.38%
77 / 97
40.00% covered (danger)
40.00%
4 / 10
44.13
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_type
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 handle_for_site
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 provision
93.88% covered (success)
93.88%
46 / 49
0.00% covered (danger)
0.00%
0 / 1
12.03
 txt_record
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
1
 get_usage
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 deprovision
0.00% covered (danger)
0.00%
0 / 4
0.00% covered (danger)
0.00%
0 / 1
12
 dkim_record
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
5.15
 find_dkim_dns_record
87.50% covered (warning)
87.50%
7 / 8
0.00% covered (danger)
0.00%
0 / 1
5.05
 company_name
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
1<?php
2/**
3 * Dev-only provisioner that does WordPress.com's job with a parent API key.
4 *
5 * @package automattic/jetpack-send
6 */
7
8namespace Automattic\Jetpack\Send;
9
10use WP_Error;
11
12if ( ! defined( 'ABSPATH' ) ) {
13    exit( 0 );
14}
15
16/**
17 * Creates the site's sub-account directly against MailChannels.
18 */
19class Local_Provisioner implements Provisioner {
20
21    /**
22     * DKIM selector for hosted keys.
23     *
24     * @var string
25     */
26    const DKIM_SELECTOR = 'jpsend';
27
28    /**
29     * Client authenticated with the parent account key.
30     *
31     * @var MailChannels_Client
32     */
33    private $parent_client;
34
35    /**
36     * Send limit to set, or null to inherit the parent's.
37     *
38     * @var int|null
39     */
40    private $send_limit;
41
42    /**
43     * DNS zone that holds the real Lockdown and DKIM values, or '' to give customers the values directly.
44     *
45     * @var string
46     */
47    private $dns_zone;
48
49    /**
50     * Constructor.
51     *
52     * @param MailChannels_Client $parent_client Parent-key client.
53     * @param int|null            $send_limit    Sub-account send limit.
54     * @param string              $dns_zone      Zone customers' CNAMEs point into, or ''.
55     */
56    public function __construct( MailChannels_Client $parent_client, $send_limit = null, $dns_zone = '' ) {
57        $this->parent_client = $parent_client;
58        $this->send_limit    = null === $send_limit ? null : (int) $send_limit;
59        $this->dns_zone      = strtolower( trim( (string) $dns_zone, " .\t\n" ) );
60    }
61
62    /**
63     * Type.
64     *
65     * @return string
66     */
67    public function get_type() {
68        return 'local';
69    }
70
71    /**
72     * A handle that is stable per site, so provisioning again reuses the sub-account.
73     *
74     * @return string
75     */
76    public static function handle_for_site() {
77        return 'jpsend' . substr( md5( home_url() ), 0, 16 );
78    }
79
80    /**
81     * Create or reuse the sub-account, issue a key, and prepare DNS records.
82     *
83     * @param string $domain Sending domain.
84     * @return array|WP_Error
85     */
86    public function provision( $domain ) {
87        $handle  = self::handle_for_site();
88        $created = $this->parent_client->create_sub_account( $handle, self::company_name() );
89        if ( is_wp_error( $created ) && 'jetpack_send_http_409' !== $created->get_error_code() ) {
90            return $created;
91        }
92
93        $key = $this->parent_client->create_sub_account_api_key( $handle );
94        if ( is_wp_error( $key ) ) {
95            return $key;
96        }
97        $api_key = (string) ( $key['key'] ?? $key['api_key'] ?? $key['value'] ?? '' );
98        if ( '' === $api_key ) {
99            return new WP_Error( 'jetpack_send_no_api_key', __( 'MailChannels did not return an API key for the new sub-account.', 'jetpack-send-pkg' ), array( 'status' => 502 ) );
100        }
101
102        if ( null !== $this->send_limit ) {
103            $limit = $this->parent_client->set_sub_account_limit( $handle, $this->send_limit );
104            if ( is_wp_error( $limit ) ) {
105                return $limit;
106            }
107        }
108
109        $required = array(
110            self::txt_record( '_mailchannels.' . $domain, 'v=mc1 auth=' . $handle, 'lockdown' ),
111        );
112        $dkim     = $this->dkim_record( $domain, new MailChannels_Client( $api_key ) );
113        if ( $dkim ) {
114            $required[] = $dkim;
115        }
116
117        // With a zone, customers add short CNAMEs and the zone holds the values, so they can change without customer edits.
118        $records      = $required;
119        $zone_records = array();
120        if ( '' !== $this->dns_zone ) {
121            $records = array();
122            foreach ( $required as $record ) {
123                $target         = $handle . '.' . $record['purpose'] . '.' . $this->dns_zone;
124                $records[]      = array_merge(
125                    $record,
126                    array(
127                        'type'  => 'CNAME',
128                        'value' => $target,
129                    )
130                );
131                $zone_records[] = array_merge( $record, array( 'name' => $target ) );
132            }
133        }
134
135        // SPF is optional because DMARC passes on aligned DKIM; it sits after Lockdown to keep the checklist order.
136        $spf = array_merge(
137            self::txt_record( $domain, 'v=spf1 include:relay.mailchannels.net ~all', 'spf' ),
138            array( 'optional' => true )
139        );
140        array_splice( $records, 1, 0, array( $spf ) );
141
142        $result = array(
143            'handle'           => $handle,
144            'api_key'          => $api_key,
145            'lockdown_auth_id' => $handle,
146            'monthly_limit'    => null === $this->send_limit ? -1 : $this->send_limit,
147            'dns_records'      => $records,
148        );
149        if ( $zone_records ) {
150            $result['zone_records'] = $zone_records;
151        }
152        return $result;
153    }
154
155    /**
156     * A required TXT record.
157     *
158     * @param string $name    Host name.
159     * @param string $value   Value.
160     * @param string $purpose `lockdown`, `spf`, or `dkim`.
161     * @return array
162     */
163    private static function txt_record( $name, $value, $purpose ) {
164        return array(
165            'type'     => 'TXT',
166            'name'     => $name,
167            'value'    => $value,
168            'purpose'  => $purpose,
169            'optional' => false,
170        );
171    }
172
173    /**
174     * Usage for the site's sub-account.
175     *
176     * @param array $account Stored account.
177     * @return array|WP_Error
178     */
179    public function get_usage( array $account ) {
180        $usage = $this->parent_client->get_sub_account_usage( $account['handle'] );
181        if ( is_wp_error( $usage ) ) {
182            return $usage;
183        }
184
185        return array(
186            'total_usage'   => (int) ( $usage['total_usage'] ?? 0 ),
187            'monthly_limit' => (int) ( $usage['monthly_limit'] ?? -1 ),
188            'period_start'  => (string) ( $usage['period_start_date'] ?? '' ),
189            'period_end'    => (string) ( $usage['period_end_date'] ?? '' ),
190        );
191    }
192
193    /**
194     * Delete the site's sub-account; one that's already gone counts as success.
195     *
196     * @param array $account Stored account.
197     * @return true|WP_Error
198     */
199    public function deprovision( array $account ) {
200        $result = $this->parent_client->delete_sub_account( $account['handle'] );
201        if ( is_wp_error( $result ) && 'jetpack_send_http_404' !== $result->get_error_code() ) {
202            return $result;
203        }
204        return true;
205    }
206
207    /**
208     * Create (or fetch the existing) hosted DKIM key's DNS record; null when unavailable.
209     *
210     * @param string              $domain Domain.
211     * @param MailChannels_Client $client Sub-account client.
212     * @return array|null
213     */
214    private function dkim_record( $domain, MailChannels_Client $client ) {
215        $response = $client->create_dkim_key( $domain, self::DKIM_SELECTOR );
216        if ( is_wp_error( $response ) ) {
217            if ( 'jetpack_send_http_409' !== $response->get_error_code() ) {
218                return null;
219            }
220            $response = $client->get_dkim_keys( $domain, self::DKIM_SELECTOR );
221            if ( is_wp_error( $response ) ) {
222                return null;
223            }
224        }
225
226        $record = self::find_dkim_dns_record( $response );
227        if ( ! $record ) {
228            return null;
229        }
230
231        return self::txt_record( self::DKIM_SELECTOR . '._domainkey.' . $domain, (string) $record['value'], 'dkim' );
232    }
233
234    /**
235     * The first entry of the first `dkim_dns_records` list anywhere in a response.
236     *
237     * @param array $data Decoded response.
238     * @return array|null
239     */
240    private static function find_dkim_dns_record( array $data ) {
241        if ( isset( $data['dkim_dns_records'][0]['value'] ) ) {
242            return $data['dkim_dns_records'][0];
243        }
244        foreach ( $data as $child ) {
245            if ( is_array( $child ) ) {
246                $found = self::find_dkim_dns_record( $child );
247                if ( $found ) {
248                    return $found;
249                }
250            }
251        }
252        return null;
253    }
254
255    /**
256     * Site title clamped to MailChannels' 3–128 character company name.
257     *
258     * @return string
259     */
260    private static function company_name() {
261        $name = trim( wp_specialchars_decode( (string) get_bloginfo( 'name' ), ENT_QUOTES ) );
262        if ( mb_strlen( $name ) < 3 ) {
263            $name = 'Jetpack Send site';
264        }
265        return mb_substr( $name, 0, 128 );
266    }
267}