Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
0.00% covered (danger)
0.00%
0 / 140
0.00% covered (danger)
0.00%
0 / 13
CRAP
0.00% covered (danger)
0.00%
0 / 1
MailChannels_Client
0.00% covered (danger)
0.00%
0 / 138
0.00% covered (danger)
0.00%
0 / 13
1980
0.00% covered (danger)
0.00%
0 / 1
 __construct
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
2
 last_response
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 / 83
0.00% covered (danger)
0.00%
0 / 1
420
 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     * The last answer's status, time taken, content type and size.
37     *
38     * @var array{status: int, ms: int, content_type: string, bytes: int}
39     */
40    private $last = array(
41        'status'       => 0,
42        'ms'           => 0,
43        'content_type' => '',
44        'bytes'        => 0,
45    );
46
47    /**
48     * Constructor.
49     *
50     * @param string $api_key API key.
51     */
52    public function __construct( $api_key ) {
53        $this->api_key = (string) $api_key;
54        // Whatever this request records, the key is never in it.
55        Debug_Log::secret( $this->api_key );
56    }
57
58    /**
59     * The last answer's status, how long it took in milliseconds, its content type and size.
60     *
61     * @since $$next-version$$
62     *
63     * @return array{status: int, ms: int, content_type: string, bytes: int}
64     */
65    public function last_response() {
66        return $this->last;
67    }
68
69    /**
70     * Send a message synchronously.
71     *
72     * @param array $payload Message_Builder::build() output.
73     * @return array|WP_Error
74     */
75    public function send( array $payload ) {
76        return $this->request( 'POST', '/send', $payload, array(), 30 );
77    }
78
79    /**
80     * Check a domain's Lockdown, SPF, DKIM and A/MX records.
81     *
82     * @param string $domain        Domain.
83     * @param string $dkim_selector DKIM selector, or '' to skip DKIM.
84     * @return array|WP_Error
85     */
86    public function check_domain( $domain, $dkim_selector ) {
87        $body = array( 'domain' => $domain );
88        if ( '' !== $dkim_selector ) {
89            $body['dkim_settings'] = array(
90                array(
91                    'dkim_domain'   => $domain,
92                    'dkim_selector' => $dkim_selector,
93                ),
94            );
95        }
96        return $this->request( 'POST', '/check-domain', $body );
97    }
98
99    /**
100     * Create a sub-account (parent key).
101     *
102     * @param string $handle       Handle.
103     * @param string $company_name Company name, 3–128 characters.
104     * @return array|WP_Error
105     */
106    public function create_sub_account( $handle, $company_name ) {
107        return $this->request(
108            'POST',
109            '/sub-account',
110            array(
111                'company_name' => $company_name,
112                'handle'       => $handle,
113            )
114        );
115    }
116
117    /**
118     * Create an API key for a sub-account (parent key). The key is only returned here.
119     *
120     * @param string $handle Handle.
121     * @return array|WP_Error
122     */
123    public function create_sub_account_api_key( $handle ) {
124        return $this->request( 'POST', '/sub-account/' . rawurlencode( $handle ) . '/api-key' );
125    }
126
127    /**
128     * Cap a sub-account's sends (parent key).
129     *
130     * @param string $handle Handle.
131     * @param int    $sends  Limit; 0 pauses the sub-account.
132     * @return array|WP_Error
133     */
134    public function set_sub_account_limit( $handle, $sends ) {
135        return $this->request( 'PUT', '/sub-account/' . rawurlencode( $handle ) . '/limit', array( 'sends' => (int) $sends ) );
136    }
137
138    /**
139     * A sub-account's usage this period (parent key).
140     *
141     * @param string $handle Handle.
142     * @return array|WP_Error
143     */
144    public function get_sub_account_usage( $handle ) {
145        // Read on every Overview visit; a successful read isn't worth a debug entry each time.
146        return $this->request( 'GET', '/sub-account/' . rawurlencode( $handle ) . '/usage', null, array(), 30, true );
147    }
148
149    /**
150     * Delete a sub-account (parent key).
151     *
152     * @param string $handle Handle.
153     * @return array|WP_Error
154     */
155    public function delete_sub_account( $handle ) {
156        return $this->request( 'DELETE', '/sub-account/' . rawurlencode( $handle ) );
157    }
158
159    /**
160     * Create a hosted DKIM key.
161     *
162     * @param string $domain   Domain.
163     * @param string $selector Selector.
164     * @return array|WP_Error
165     */
166    public function create_dkim_key( $domain, $selector ) {
167        return $this->request(
168            'POST',
169            '/domains/' . rawurlencode( $domain ) . '/dkim-keys',
170            array(
171                'selector'   => $selector,
172                'algorithm'  => 'rsa',
173                'key_length' => 2048,
174            )
175        );
176    }
177
178    /**
179     * List hosted DKIM keys for a selector, with their DNS records.
180     *
181     * @param string $domain   Domain.
182     * @param string $selector Selector.
183     * @return array|WP_Error
184     */
185    public function get_dkim_keys( $domain, $selector ) {
186        return $this->request(
187            'GET',
188            '/domains/' . rawurlencode( $domain ) . '/dkim-keys',
189            null,
190            array(
191                'selector'           => $selector,
192                'include_dns_record' => 'true',
193            )
194        );
195    }
196
197    /**
198     * Make a request and normalize failures into WP_Error.
199     *
200     * @param string     $method  HTTP method.
201     * @param string     $path    Path under BASE_URL.
202     * @param array|null $body    JSON body.
203     * @param array      $query   Query arguments.
204     * @param int        $timeout Seconds.
205     * @param bool       $quiet   Leave a successful answer out of the debug log.
206     * @return array|WP_Error Decoded body, or an error with data `{status, errors, ms}`; a network error keeps
207     *                        WordPress's code, the cURL error number and when it happened.
208     */
209    private function request( $method, $path, $body = null, array $query = array(), $timeout = 30, $quiet = false ) {
210        $url = self::BASE_URL . $path;
211        if ( $query ) {
212            $url = add_query_arg( $query, $url );
213        }
214
215        $args = array(
216            'method'  => $method,
217            'timeout' => $timeout,
218            'headers' => array(
219                'X-Api-Key' => $this->api_key,
220                'Accept'    => 'application/json',
221            ),
222        );
223        if ( null !== $body ) {
224            $args['headers']['Content-Type'] = 'application/json';
225            $args['body']                    = wp_json_encode( $body, JSON_UNESCAPED_SLASHES );
226        }
227
228        $started  = microtime( true );
229        $response = wp_remote_request( $url, $args );
230        $ms       = (int) round( ( microtime( true ) - $started ) * 1000 );
231        // The path without its query, which is all the debug log needs.
232        $where = array(
233            'method' => $method,
234            'path'   => $path,
235        );
236        if ( is_wp_error( $response ) ) {
237            $message = $response->get_error_message();
238            $errno   = preg_match( '/cURL error (\d+)/i', $message, $match ) ? (int) $match[1] : 0;
239            $data    = array(
240                'status'    => 0,
241                'errors'    => array(),
242                'wp_code'   => (string) $response->get_error_code(),
243                'errno'     => $errno,
244                'phase'     => Failure_Reason::transport_phase( $errno, $message ),
245                'ms'        => $ms,
246                'timeout_s' => (int) $timeout,
247            );
248            Debug_Log::add(
249                'error',
250                'sending',
251                'api.transport_error',
252                'after_send' === $data['phase']
253                    /* translators: %s: the network error, e.g. "cURL error 28: Operation timed out". */
254                    ? sprintf( __( 'No answer after sending: %s', 'jetpack-send-pkg' ), $message )
255                    /* translators: %s: the network error, e.g. "cURL error 7: Failed to connect". */
256                    : sprintf( __( 'Couldn’t reach the email service: %s', 'jetpack-send-pkg' ), $message ),
257                $where + $data + array( 'message' => $message )
258            );
259            return new WP_Error( 'jetpack_send_transport', $message, $data );
260        }
261
262        $code       = (int) wp_remote_retrieve_response_code( $response );
263        $raw        = (string) wp_remote_retrieve_body( $response );
264        $decoded    = '' === $raw ? array() : json_decode( $raw, true );
265        $this->last = array(
266            'status'       => $code,
267            'ms'           => $ms,
268            'content_type' => (string) wp_remote_retrieve_header( $response, 'content-type' ),
269            'bytes'        => strlen( $raw ),
270        );
271        $ok         = $code >= 200 && $code < 300;
272        if ( ! $quiet || ! $ok ) {
273            Debug_Log::add(
274                $ok ? 'debug' : ( 429 === $code ? 'warning' : 'error' ),
275                'sending',
276                'api.call',
277                /* translators: 1: HTTP method, 2: the API path, 3: HTTP status, 4: milliseconds. */
278                sprintf( __( '%1$s %2$s → HTTP %3$d in %4$d ms.', 'jetpack-send-pkg' ), $method, $path, $code, $ms ),
279                $where + $this->last + array(
280                    'request_id' => is_array( $decoded ) && is_scalar( $decoded['request_id'] ?? null ) ? (string) $decoded['request_id'] : '',
281                    'body'       => null === $decoded && '' !== $raw ? '<not JSON, ' . strlen( $raw ) . ' bytes>' : Debug_Log::service_body( $decoded ),
282                )
283            );
284        }
285
286        if ( $code < 200 || $code >= 300 ) {
287            $errors = self::extract_errors( $decoded );
288            return new WP_Error(
289                'jetpack_send_http_' . $code,
290                $errors
291                    ? implode( ' ', $errors )
292                    /* translators: %d: HTTP status code. */
293                    : sprintf( __( 'The sending service returned HTTP %d.', 'jetpack-send-pkg' ), $code ),
294                array(
295                    'status'       => $code,
296                    'errors'       => $errors,
297                    'ms'           => $ms,
298                    'content_type' => $this->last['content_type'],
299                    'bytes'        => $this->last['bytes'],
300                )
301            );
302        }
303
304        return is_array( $decoded ) ? $decoded : array();
305    }
306
307    /**
308     * Pull human-readable messages out of an error body.
309     *
310     * @param mixed $decoded Decoded JSON.
311     * @return string[] Empty when the body is not JSON or has no recognized error field.
312     */
313    private static function extract_errors( $decoded ) {
314        if ( is_array( $decoded ) && isset( $decoded['errors'] ) && is_array( $decoded['errors'] ) ) {
315            $messages = array();
316            foreach ( $decoded['errors'] as $error ) {
317                if ( is_string( $error ) ) {
318                    $messages[] = $error;
319                } elseif ( is_array( $error ) && isset( $error['message'] ) ) {
320                    $messages[] = (string) $error['message'];
321                }
322            }
323            return $messages;
324        }
325        foreach ( array( 'error', 'message' ) as $key ) {
326            if ( is_array( $decoded ) && isset( $decoded[ $key ] ) && is_string( $decoded[ $key ] ) ) {
327                return array( $decoded[ $key ] );
328            }
329        }
330        return array();
331    }
332}