Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.08% covered (warning)
77.08%
37 / 48
66.67% covered (warning)
66.67%
4 / 6
CRAP
0.00% covered (danger)
0.00%
0 / 1
Email_Service
77.08% covered (warning)
77.08%
37 / 48
66.67% covered (warning)
66.67%
4 / 6
19.08
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 api_send_auth_email
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
8
 send_email_request
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 resend_auth_email
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
4.10
 generate_auth_code
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 mask_email_address
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Class used to define Email Service.
4 *
5 * @package automattic/jetpack-account-protection
6 */
7
8namespace Automattic\Jetpack\Account_Protection;
9
10use Automattic\Jetpack\Connection\Client;
11use Automattic\Jetpack\Connection\Manager as Connection_Manager;
12use Jetpack_Options;
13
14/**
15 * Class Email_Service
16 */
17class Email_Service {
18    /**
19     * Connection manager dependency.
20     *
21     * @var Connection_Manager
22     */
23    private $connection_manager;
24
25    /**
26     * Constructor for dependency injection.
27     *
28     * @param Connection_Manager|null $connection_manager Connection manager dependency.
29     */
30    public function __construct(
31        ?Connection_Manager $connection_manager = null
32    ) {
33        $this->connection_manager = $connection_manager ?? new Connection_Manager();
34    }
35
36    /**
37     * Send the email using the API.
38     *
39     * @param int    $user_id The user ID.
40     * @param string $auth_code The authentication code.
41     *
42     * @return true|\WP_Error True if the email was sent successfully, \WP_Error otherwise.
43     */
44    public function api_send_auth_email( int $user_id, string $auth_code ) {
45        $blog_id = Jetpack_Options::get_option( 'id' );
46
47        /**
48         * Filters whether the Account Protection verification email should be handled externally.
49         *
50         * When the filter returns a truthy value, the default WPCOM API email send is skipped,
51         * allowing sites to deliver the email locally (e.g. via `wp_mail()`).
52         *
53         * @since 0.3.0
54         *
55         * @param bool   $handled  Whether the email has been handled. Default false.
56         * @param int    $user_id  The user ID.
57         * @param string $auth_code The authentication code.
58         * @param int    $blog_id  The blog ID, or false if not available.
59         */
60        $handled = apply_filters( 'jetpack_account_protection_send_auth_email', false, $user_id, $auth_code, $blog_id );
61
62        if ( $handled ) {
63            return true;
64        }
65
66        if ( ! $blog_id || ! $this->connection_manager->is_connected() ) {
67            return new \WP_Error( 'jetpack_connection_error', __( 'Jetpack is not connected. Please connect and try again.', 'jetpack-account-protection' ) );
68        }
69
70        $body = array(
71            'user_id' => $user_id,
72            'code'    => $auth_code,
73        );
74
75        $response = $this->send_email_request( (int) $blog_id, $body );
76        if ( is_wp_error( $response ) || empty( $response['body'] ) ) {
77            return new \WP_Error( 'email_send_error', __( 'Failed to send authentication code. Please try again.', 'jetpack-account-protection' ) );
78        }
79
80        $response_code = wp_remote_retrieve_response_code( $response );
81        $body          = json_decode( wp_remote_retrieve_body( $response ), true );
82        if ( 200 !== $response_code ) {
83            return new \WP_Error( $body['code'] ?? 'email_send_error', $body['message'] ?? __( 'Failed to send authentication code. Please try again.', 'jetpack-account-protection' ) );
84        }
85
86        if ( empty( $body['email_send_success'] ) ) {
87            return new \WP_Error( 'email_send_error', __( 'Failed to send authentication code. Please try again.', 'jetpack-account-protection' ) );
88        }
89
90        return true;
91    }
92
93    /**
94     * Dependency decoupling for the static call to the client.
95     *
96     * @param int   $blog_id Blog ID.
97     * @param array $body The request body.
98     * @return array|\WP_Error Response data or error.
99     */
100    protected function send_email_request( int $blog_id, array $body ) {
101        return Client::wpcom_json_api_request_as_blog(
102            sprintf( '/sites/%d/jetpack-protect-send-verification-code', $blog_id ),
103            '2',
104            array(
105                'method' => 'POST',
106            ),
107            $body,
108            'wpcom'
109        );
110    }
111
112    /**
113     * Resend email attempts.
114     *
115     * @param int    $user_id The user ID.
116     * @param array  $transient_data The transient data.
117     * @param string $token The token.
118     *
119     * @return true|\WP_Error True if the email was resent successfully, \WP_Error otherwise.
120     */
121    public function resend_auth_email( int $user_id, array $transient_data, string $token ) {
122        if ( $transient_data['requests'] >= Config::PASSWORD_DETECTION_EMAIL_REQUEST_LIMIT ) {
123            return new \WP_Error( 'email_request_limit_exceeded', __( 'Email request limit exceeded. Please try again later.', 'jetpack-account-protection' ) );
124        }
125
126        $auth_code                   = $this->generate_auth_code();
127        $transient_data['auth_code'] = $auth_code;
128
129        $resend = $this->api_send_auth_email( $user_id, $auth_code );
130        if ( is_wp_error( $resend ) ) {
131            return $resend;
132        }
133
134        ++$transient_data['requests'];
135
136        if ( ! set_transient( Config::PREFIX . "_{$token}", $transient_data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ) ) {
137            return new \WP_Error( 'transient_set_error', __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ) );
138        }
139
140        return true;
141    }
142
143    /**
144     * Generate an auth code.
145     *
146     * @return string The generated auth code.
147     */
148    public function generate_auth_code(): string {
149        return (string) wp_rand( 100000, 999999 );
150    }
151
152    /**
153     * Mask an email address like d*****@g*****.com.
154     *
155     * @param string $email The email address to mask.
156     *
157     * @return string The masked email address.
158     */
159    public function mask_email_address( string $email ): string {
160        $parts        = explode( '@', $email );
161        $name         = substr( $parts[0], 0, 1 ) . str_repeat( '*', strlen( $parts[0] ) - 1 );
162        $domain_parts = explode( '.', $parts[1] );
163        $domain       = substr( $domain_parts[0], 0, 1 ) . str_repeat( '*', strlen( $domain_parts[0] ) - 1 );
164
165        // Join all domain parts except the first one with dots
166        $tld = implode( '.', array_slice( $domain_parts, 1 ) );
167
168        return "{$name}@{$domain}.{$tld}";
169    }
170}