Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 96
0.00% covered (danger)
0.00%
0 / 12
CRAP
0.00% covered (danger)
0.00%
0 / 1
MailChannels_Client
0.00% covered (danger)
0.00%
0 / 94
0.00% covered (danger)
0.00%
0 / 12
1056
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 send
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 check_domain
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
6
 create_sub_account
0.00% covered (danger)
0.00%
0 / 8
0.00% covered (danger)
0.00%
0 / 1
2
 create_sub_account_api_key
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_sub_account_limit
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_sub_account_usage
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 delete_sub_account
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 create_dkim_key
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 get_dkim_keys
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 request
0.00% covered (danger)
0.00%
0 / 41
0.00% covered (danger)
0.00%
0 / 1
90
 extract_errors
0.00% covered (danger)
0.00%
0 / 12
0.00% covered (danger)
0.00%
0 / 1
156
1<?php
2/**
3 * Thin client for the MailChannels Email API.
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 * Calls MailChannels endpoints with one API key.
18 */
19class MailChannels_Client {
20
21    /**
22     * API base URL.
23     *
24     * @var string
25     */
26    const BASE_URL = 'https://api.mailchannels.net/tx/v1';
27
28    /**
29     * API key sent as `X-Api-Key`.
30     *
31     * @var string
32     */
33    private $api_key;
34
35    /**
36     * Constructor.
37     *
38     * @param string $api_key API key.
39     */
40    public function __construct( $api_key ) {
41        $this->api_key = (string) $api_key;
42    }
43
44    /**
45     * Send a message synchronously.
46     *
47     * @param array $payload Message_Builder::build() output.
48     * @return array|WP_Error
49     */
50    public function send( array $payload ) {
51        return $this->request( 'POST', '/send', $payload, array(), 15 );
52    }
53
54    /**
55     * Check a domain's Lockdown, SPF, DKIM and A/MX records.
56     *
57     * @param string $domain        Domain.
58     * @param string $dkim_selector DKIM selector, or '' to skip DKIM.
59     * @return array|WP_Error
60     */
61    public function check_domain( $domain, $dkim_selector ) {
62        $body = array( 'domain' => $domain );
63        if ( '' !== $dkim_selector ) {
64            $body['dkim_settings'] = array(
65                array(
66                    'dkim_domain'   => $domain,
67                    'dkim_selector' => $dkim_selector,
68                ),
69            );
70        }
71        return $this->request( 'POST', '/check-domain', $body );
72    }
73
74    /**
75     * Create a sub-account (parent key).
76     *
77     * @param string $handle       Handle.
78     * @param string $company_name Company name, 3–128 characters.
79     * @return array|WP_Error
80     */
81    public function create_sub_account( $handle, $company_name ) {
82        return $this->request(
83            'POST',
84            '/sub-account',
85            array(
86                'company_name' => $company_name,
87                'handle'       => $handle,
88            )
89        );
90    }
91
92    /**
93     * Create an API key for a sub-account (parent key). The key is only returned here.
94     *
95     * @param string $handle Handle.
96     * @return array|WP_Error
97     */
98    public function create_sub_account_api_key( $handle ) {
99        return $this->request( 'POST', '/sub-account/' . rawurlencode( $handle ) . '/api-key' );
100    }
101
102    /**
103     * Cap a sub-account's sends (parent key).
104     *
105     * @param string $handle Handle.
106     * @param int    $sends  Limit; 0 pauses the sub-account.
107     * @return array|WP_Error
108     */
109    public function set_sub_account_limit( $handle, $sends ) {
110        return $this->request( 'PUT', '/sub-account/' . rawurlencode( $handle ) . '/limit', array( 'sends' => (int) $sends ) );
111    }
112
113    /**
114     * A sub-account's usage this period (parent key).
115     *
116     * @param string $handle Handle.
117     * @return array|WP_Error
118     */
119    public function get_sub_account_usage( $handle ) {
120        return $this->request( 'GET', '/sub-account/' . rawurlencode( $handle ) . '/usage' );
121    }
122
123    /**
124     * Delete a sub-account (parent key).
125     *
126     * @param string $handle Handle.
127     * @return array|WP_Error
128     */
129    public function delete_sub_account( $handle ) {
130        return $this->request( 'DELETE', '/sub-account/' . rawurlencode( $handle ) );
131    }
132
133    /**
134     * Create a hosted DKIM key.
135     *
136     * @param string $domain   Domain.
137     * @param string $selector Selector.
138     * @return array|WP_Error
139     */
140    public function create_dkim_key( $domain, $selector ) {
141        return $this->request(
142            'POST',
143            '/domains/' . rawurlencode( $domain ) . '/dkim-keys',
144            array(
145                'selector'   => $selector,
146                'algorithm'  => 'rsa',
147                'key_length' => 2048,
148            )
149        );
150    }
151
152    /**
153     * List hosted DKIM keys for a selector, with their DNS records.
154     *
155     * @param string $domain   Domain.
156     * @param string $selector Selector.
157     * @return array|WP_Error
158     */
159    public function get_dkim_keys( $domain, $selector ) {
160        return $this->request(
161            'GET',
162            '/domains/' . rawurlencode( $domain ) . '/dkim-keys',
163            null,
164            array(
165                'selector'           => $selector,
166                'include_dns_record' => 'true',
167            )
168        );
169    }
170
171    /**
172     * Make a request and normalize failures into WP_Error.
173     *
174     * @param string     $method  HTTP method.
175     * @param string     $path    Path under BASE_URL.
176     * @param array|null $body    JSON body.
177     * @param array      $query   Query arguments.
178     * @param int        $timeout Seconds.
179     * @return array|WP_Error Decoded body, or an error with data `{status, errors}`.
180     */
181    private function request( $method, $path, $body = null, array $query = array(), $timeout = 30 ) {
182        $url = self::BASE_URL . $path;
183        if ( $query ) {
184            $url = add_query_arg( $query, $url );
185        }
186
187        $args = array(
188            'method'  => $method,
189            'timeout' => $timeout,
190            'headers' => array(
191                'X-Api-Key' => $this->api_key,
192                'Accept'    => 'application/json',
193            ),
194        );
195        if ( null !== $body ) {
196            $args['headers']['Content-Type'] = 'application/json';
197            $args['body']                    = wp_json_encode( $body, JSON_UNESCAPED_SLASHES );
198        }
199
200        $response = wp_remote_request( $url, $args );
201        if ( is_wp_error( $response ) ) {
202            return new WP_Error(
203                'jetpack_send_transport',
204                $response->get_error_message(),
205                array(
206                    'status' => 0,
207                    'errors' => array(),
208                )
209            );
210        }
211
212        $code    = (int) wp_remote_retrieve_response_code( $response );
213        $raw     = (string) wp_remote_retrieve_body( $response );
214        $decoded = '' === $raw ? array() : json_decode( $raw, true );
215
216        if ( $code < 200 || $code >= 300 ) {
217            $errors = self::extract_errors( $decoded );
218            return new WP_Error(
219                'jetpack_send_http_' . $code,
220                $errors
221                    ? implode( ' ', $errors )
222                    /* translators: %d: HTTP status code. */
223                    : sprintf( __( 'MailChannels returned HTTP %d.', 'jetpack-send-pkg' ), $code ),
224                array(
225                    'status' => $code,
226                    'errors' => $errors,
227                )
228            );
229        }
230
231        return is_array( $decoded ) ? $decoded : array();
232    }
233
234    /**
235     * Pull human-readable messages out of an error body.
236     *
237     * @param mixed $decoded Decoded JSON.
238     * @return string[] Empty when the body is not JSON or has no recognized error field.
239     */
240    private static function extract_errors( $decoded ) {
241        if ( is_array( $decoded ) && isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) ) {
242            $messages = array();
243            foreach ( $decoded['errors'] as $error ) {
244                if ( is_string( $error ) ) {
245                    $messages[] = $error;
246                } elseif ( is_array( $error ) && isset( $error['message'] ) ) {
247                    $messages[] = (string) $error['message'];
248                }
249            }
250            return $messages;
251        }
252        foreach ( array( 'error', 'message' ) as $key ) {
253            if ( is_array( $decoded ) && isset( $decoded[ $key ] ) && is_string( $decoded[ $key ] ) ) {
254                return array( $decoded[ $key ] );
255            }
256        }
257        return array();
258    }
259}