Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.32% covered (success)
94.32%
83 / 88
60.00% covered (warning)
60.00%
3 / 5
CRAP
0.00% covered (danger)
0.00%
0 / 1
Mailer
96.51% covered (success)
96.51%
83 / 86
60.00% covered (warning)
60.00%
3 / 5
28
0.00% covered (danger)
0.00%
0 / 1
 pre_wp_mail
95.24% covered (success)
95.24%
20 / 21
0.00% covered (danger)
0.00%
0 / 1
8
 deliver
100.00% covered (success)
100.00%
11 / 11
100.00% covered (success)
100.00%
1 / 1
7
 send_test
87.50% covered (warning)
87.50%
14 / 16
0.00% covered (danger)
0.00%
0 / 1
4.03
 log
100.00% covered (success)
100.00%
12 / 12
100.00% covered (success)
100.00%
1 / 1
2
 log_message
100.00% covered (success)
100.00%
26 / 26
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2/**
3 * Routes wp_mail() through MailChannels.
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 * The `pre_wp_mail` handler.
18 */
19class Mailer {
20
21    /**
22     * Send through MailChannels, or hand the message back to core.
23     *
24     * @param null|bool $return Short-circuit value from earlier callbacks.
25     * @param array     $atts   wp_mail() attributes.
26     * @return null|bool True when sent, null to let core send it, false when it failed with fallback off.
27     */
28    public static function pre_wp_mail( $return, $atts ) {
29        if ( null !== $return ) {
30            return $return;
31        }
32
33        $settings = Settings::get();
34        if ( empty( $settings['enabled'] ) ) {
35            return null;
36        }
37        $account = Account::get();
38        if ( ! $account ) {
39            return null;
40        }
41
42        $atts    = (array) $atts;
43        $payload = Message_Builder::build( $atts, $settings, $account );
44        $result  = is_wp_error( $payload ) ? $payload : self::deliver( $payload, $account );
45        if ( ! is_wp_error( $result ) ) {
46            self::log( $atts, $payload, Email_Log::STATUS_SENT, $result['request_id'], '', 'wp_mail' );
47            /** This action is documented in wp-includes/pluggable.php */
48            do_action( 'wp_mail_succeeded', $atts );
49            return true;
50        }
51
52        // A message MailChannels can't represent always goes to core, whatever the fallback setting.
53        if ( ! empty( $settings['fallback'] ) || 'jetpack_send_unsupported' === $result->get_error_code() ) {
54            self::log( $atts, $payload, Email_Log::STATUS_FALLBACK, '', $result->get_error_message(), 'wp_mail' );
55            return null;
56        }
57
58        self::log( $atts, $payload, Email_Log::STATUS_FAILED, '', $result->get_error_message(), 'wp_mail' );
59        /** This action is documented in wp-includes/pluggable.php */
60        do_action( 'wp_mail_failed', new WP_Error( 'wp_mail_failed', $result->get_error_message(), $atts ) );
61        return false;
62    }
63
64    /**
65     * Send one built message.
66     *
67     * @param array $payload MailChannels payload.
68     * @param array $account Provisioned account.
69     * @return array{request_id: string}|WP_Error
70     */
71    private static function deliver( array $payload, array $account ) {
72        $response = ( new MailChannels_Client( $account['api_key'] ) )->send( $payload );
73        if ( is_wp_error( $response ) ) {
74            return $response;
75        }
76
77        $result = isset( $response['results'][0] ) && is_array( $response['results'][0] ) ? $response['results'][0] : array();
78        if ( 'sent' !== ( $result['status'] ?? '' ) ) {
79            return new WP_Error(
80                'jetpack_send_rejected',
81                ! empty( $result['reason'] ) && is_string( $result['reason'] ) ? $result['reason'] : __( 'MailChannels did not accept the message.', 'jetpack-send-pkg' ),
82                array( 'status' => 502 )
83            );
84        }
85
86        return array( 'request_id' => (string) ( $response['request_id'] ?? '' ) );
87    }
88
89    /**
90     * Send a test email, skipping the enabled check and fallback so the result reflects MailChannels.
91     *
92     * @param string $to Recipient.
93     * @return array{request_id: string}|WP_Error
94     */
95    public static function send_test( $to ) {
96        $account = Account::get();
97        if ( ! $account ) {
98            return new WP_Error( 'jetpack_send_not_set_up', __( 'Set up Send before sending a test email.', 'jetpack-send-pkg' ), array( 'status' => 400 ) );
99        }
100
101        $atts = array(
102            'to'          => $to,
103            'subject'     => __( 'Jetpack Send test email', 'jetpack-send-pkg' ),
104            /* translators: %s: site URL. */
105            'message'     => sprintf( __( 'This test email from %s was delivered by Jetpack Send.', 'jetpack-send-pkg' ), home_url() ),
106            'headers'     => '',
107            'attachments' => array(),
108        );
109
110        $payload = Message_Builder::build( $atts, Settings::get(), $account );
111        $result  = is_wp_error( $payload ) ? $payload : self::deliver( $payload, $account );
112        if ( is_wp_error( $result ) ) {
113            self::log( $atts, $payload, Email_Log::STATUS_FAILED, '', $result->get_error_message(), 'test' );
114        } else {
115            self::log( $atts, $payload, Email_Log::STATUS_SENT, $result['request_id'], '', 'test' );
116        }
117
118        return $result;
119    }
120
121    /**
122     * Write a log row.
123     *
124     * @param array          $atts       wp_mail() attributes.
125     * @param array|WP_Error $payload    MailChannels payload, or why it wasn't built.
126     * @param string         $status     Email_Log status.
127     * @param string         $request_id MailChannels request ID.
128     * @param string         $error      Error message.
129     * @param string         $source     `wp_mail` or `test`.
130     * @return void
131     */
132    private static function log( array $atts, $payload, $status, $request_id, $error, $source ) {
133        $to = $atts['to'] ?? '';
134        Email_Log::insert(
135            array(
136                'recipients' => is_array( $to ) ? implode( ', ', $to ) : (string) $to,
137                'subject'    => (string) ( $atts['subject'] ?? '' ),
138                'status'     => $status,
139                'error'      => $error,
140                'request_id' => $request_id,
141                'source'     => $source,
142                'message'    => self::log_message( $atts, $payload ),
143            )
144        );
145    }
146
147    /**
148     * The content the log keeps for previewing: the message as sent, or as wp_mail() received it.
149     *
150     * @param array          $atts    wp_mail() attributes.
151     * @param array|WP_Error $payload MailChannels payload, or why it wasn't built.
152     * @return array|null Null when the content isn't kept.
153     */
154    public static function log_message( array $atts, $payload ) {
155        /**
156         * Whether Send keeps an email's content in its log so it can be previewed.
157         *
158         * @since $$next-version$$
159         *
160         * @param bool  $keep Default true.
161         * @param array $atts wp_mail() attributes.
162         */
163        if ( ! apply_filters( 'jetpack_send_log_email_content', true, $atts ) ) {
164            return null;
165        }
166
167        if ( is_array( $payload ) ) {
168            $personalization = $payload['personalizations'][0];
169            // The HTML part comes last when there is one.
170            $part    = end( $payload['content'] );
171            $message = array(
172                'from'         => $payload['from'],
173                'reply_to'     => $payload['reply_to'] ?? null,
174                'cc'           => $personalization['cc'] ?? array(),
175                'bcc'          => $personalization['bcc'] ?? array(),
176                'content_type' => $part['type'],
177                'body'         => $part['value'],
178                'attachments'  => array_column( $payload['attachments'] ?? array(), 'filename' ),
179            );
180        } else {
181            $headers = Message_Builder::parse_headers( $atts['headers'] ?? '' );
182            /** This filter is documented in wp-includes/pluggable.php */
183            $content_type = (string) apply_filters( 'wp_mail_content_type', '' !== $headers['content_type'] ? $headers['content_type'] : 'text/plain' );
184            $message      = array(
185                'from'         => '' !== $headers['from']['email'] ? $headers['from'] : null,
186                'reply_to'     => $headers['reply_to'][0] ?? null,
187                'cc'           => $headers['cc'],
188                'bcc'          => $headers['bcc'],
189                'content_type' => 'text/html' === strtolower( $content_type ) ? 'text/html' : 'text/plain',
190                'body'         => (string) ( $atts['message'] ?? '' ),
191                'attachments'  => array_column( Message_Builder::attachment_files( $atts['attachments'] ?? array() ), 'filename' ),
192            );
193        }
194
195        return strlen( $message['body'] ) > Email_Log::MAX_BODY_BYTES ? null : $message;
196    }
197}