Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
79.25% covered (warning)
79.25%
42 / 53
77.78% covered (warning)
77.78%
7 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
Email_Service
79.25% covered (warning)
79.25%
42 / 53
77.78% covered (warning)
77.78%
7 / 9
23.58
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
83.33% covered (warning)
83.33%
10 / 12
0.00% covered (danger)
0.00%
0 / 1
5.12
 get_user_email_count_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 user_email_limit_reached
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 count_user_email
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 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 || $this->user_email_limit_reached( $user_id ) ) {
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        $this->count_user_email( $user_id );
135        ++$transient_data['requests'];
136
137        if ( ! set_transient( Config::PREFIX . "_{$token}", $transient_data, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION ) ) {
138            return new \WP_Error( 'transient_set_error', __( 'Failed to set transient data. Please try again.', 'jetpack-account-protection' ) );
139        }
140
141        return true;
142    }
143
144    /**
145     * Get the name of the transient counting the codes emailed to a user across the network.
146     *
147     * @param int $user_id The user ID.
148     *
149     * @return string
150     */
151    public static function get_user_email_count_key( int $user_id ): string {
152        return Config::PREFIX . "_email_requests_user_{$user_id}";
153    }
154
155    /**
156     * Whether the user has been emailed as many codes as the limit allows, counted across the network.
157     *
158     * @param int $user_id The user ID.
159     *
160     * @return bool
161     */
162    public function user_email_limit_reached( int $user_id ): bool {
163        return (int) get_site_transient( self::get_user_email_count_key( $user_id ) ) >= Config::PASSWORD_DETECTION_EMAIL_REQUEST_LIMIT;
164    }
165
166    /**
167     * Count a code emailed to a user.
168     *
169     * @param int $user_id The user ID.
170     *
171     * @return void
172     */
173    public function count_user_email( int $user_id ): void {
174        $key = self::get_user_email_count_key( $user_id );
175
176        set_site_transient( $key, (int) get_site_transient( $key ) + 1, Config::PASSWORD_DETECTION_EMAIL_SENT_EXPIRATION );
177    }
178
179    /**
180     * Generate an auth code.
181     *
182     * @return string The generated auth code.
183     */
184    public function generate_auth_code(): string {
185        return (string) wp_rand( 100000, 999999 );
186    }
187
188    /**
189     * Mask an email address like d*****@g*****.com.
190     *
191     * @param string $email The email address to mask.
192     *
193     * @return string The masked email address.
194     */
195    public function mask_email_address( string $email ): string {
196        $parts        = explode( '@', $email );
197        $name         = substr( $parts[0], 0, 1 ) . str_repeat( '*', strlen( $parts[0] ) - 1 );
198        $domain_parts = explode( '.', $parts[1] );
199        $domain       = substr( $domain_parts[0], 0, 1 ) . str_repeat( '*', strlen( $domain_parts[0] ) - 1 );
200
201        // Join all domain parts except the first one with dots
202        $tld = implode( '.', array_slice( $domain_parts, 1 ) );
203
204        return "{$name}@{$domain}.{$tld}";
205    }
206}