Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
77.42% covered (warning)
77.42%
144 / 186
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Email_Sender
78.26% covered (warning)
78.26%
144 / 184
50.00% covered (danger)
50.00%
4 / 8
56.44
0.00% covered (danger)
0.00%
0 / 1
 maybe_init
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 init
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 handle_send
67.86% covered (warning)
67.86%
76 / 112
0.00% covered (danger)
0.00%
0 / 1
43.13
 send_email
100.00% covered (success)
100.00%
20 / 20
100.00% covered (success)
100.00%
1 / 1
2
 build_email_html
100.00% covered (success)
100.00%
21 / 21
100.00% covered (success)
100.00%
1 / 1
3
 log_send
81.82% covered (warning)
81.82%
9 / 11
0.00% covered (danger)
0.00%
0 / 1
3.05
 mask_email
85.71% covered (warning)
85.71%
6 / 7
0.00% covered (danger)
0.00%
0 / 1
2.01
 get_log_for_resource
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
1<?php
2/**
3 * PayPal Payment Link email sender.
4 *
5 * Handles sending payment links via wp_mail() and tracking send history.
6 * Registered as an AJAX handler for the admin detail view.
7 *
8 * @package automattic/jetpack-paypal-payments
9 * @since 0.9.0
10 */
11
12namespace Automattic\Jetpack\PaypalPayments;
13
14if ( ! defined( 'ABSPATH' ) ) {
15    exit;
16}
17
18/**
19 * Class PayPal_Email_Sender
20 */
21class PayPal_Email_Sender {
22
23    /**
24     * Option key for storing the send log.
25     *
26     * @var string
27     */
28    const LOG_OPTION_KEY = 'jetpack_paypal_email_send_log';
29
30    /**
31     * Maximum number of log entries to retain.
32     *
33     * @var int
34     */
35    const MAX_LOG_ENTRIES = 50;
36
37    /**
38     * AJAX action name.
39     *
40     * @var string
41     */
42    const AJAX_ACTION = 'paypal_send_payment_link';
43
44    /**
45     * Initialize AJAX hooks when the API-managed buttons are enabled.
46     *
47     * @since 0.9.0
48     * @return void
49     */
50    public static function maybe_init() {
51        if ( ! PayPal_Payment_Buttons::is_api_managed_enabled() ) {
52            return;
53        }
54
55        self::init();
56    }
57
58    /**
59     * Initialize AJAX hooks.
60     */
61    public static function init() {
62        add_action( 'wp_ajax_' . self::AJAX_ACTION, array( __CLASS__, 'handle_send' ) );
63    }
64
65    /**
66     * Handle the AJAX send request.
67     */
68    public static function handle_send() {
69        // Verify nonce.
70        if ( ! check_ajax_referer( self::AJAX_ACTION, '_wpnonce', false ) ) {
71            wp_send_json_error(
72                array( 'message' => __( 'Security check failed.', 'jetpack-paypal-payments' ) ),
73                403,
74                JSON_HEX_TAG | JSON_HEX_AMP
75            );
76        }
77
78        // Check capability.
79        if ( ! current_user_can( 'manage_options' ) ) {
80            wp_send_json_error(
81                array( 'message' => __( 'You do not have permission to send emails.', 'jetpack-paypal-payments' ) ),
82                403,
83                JSON_HEX_TAG | JSON_HEX_AMP
84            );
85        }
86
87        // Validate inputs.
88        $recipient   = isset( $_POST['recipient'] ) ? sanitize_email( wp_unslash( $_POST['recipient'] ) ) : '';
89        $message     = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '';
90        $resource_id = isset( $_POST['resource_id'] ) ? sanitize_text_field( wp_unslash( $_POST['resource_id'] ) ) : '';
91
92        if ( ! is_email( $recipient ) ) {
93            wp_send_json_error(
94                array( 'message' => __( 'Please enter a valid email address.', 'jetpack-paypal-payments' ) ),
95                400,
96                JSON_HEX_TAG | JSON_HEX_AMP
97            );
98        }
99
100        // Reject a malformed ID before the rate limit and the PayPal read.
101        if ( ! PayPal_Attribute_Mapper::is_valid_resource_id( $resource_id ) ) {
102            wp_send_json_error(
103                array( 'message' => __( 'Invalid or missing PayPal payment link.', 'jetpack-paypal-payments' ) ),
104                400,
105                JSON_HEX_TAG | JSON_HEX_AMP
106            );
107        }
108
109        // Rate limiting: max 10 sends per 60-second window + 50/day cap per user.
110        $user_id     = get_current_user_id();
111        $rate_key    = 'paypal_email_rate_' . $user_id;
112        $daily_key   = 'paypal_email_daily_' . $user_id . '_' . gmdate( 'Y-m-d' );
113        $rate_data   = get_transient( $rate_key );
114        $rate_count  = is_array( $rate_data ) ? (int) $rate_data['count']
115            : ( is_numeric( $rate_data ) ? (int) $rate_data : 0 );
116        $daily_data  = get_transient( $daily_key );
117        $daily_count = is_array( $daily_data ) ? (int) $daily_data['count']
118            : ( is_numeric( $daily_data ) ? (int) $daily_data : 0 );
119        $rate_limit  = 10;
120        $rate_window = 60; // seconds.
121        $daily_limit = 50;
122
123        if ( $rate_count >= $rate_limit ) {
124            wp_send_json_error(
125                array( 'message' => __( 'Rate limit exceeded. Please wait a minute before sending more emails.', 'jetpack-paypal-payments' ) ),
126                429,
127                JSON_HEX_TAG | JSON_HEX_AMP
128            );
129        }
130
131        if ( $daily_count >= $daily_limit ) {
132            wp_send_json_error(
133                array( 'message' => __( 'Daily email limit reached. Please try again tomorrow.', 'jetpack-paypal-payments' ) ),
134                429,
135                JSON_HEX_TAG | JSON_HEX_AMP
136            );
137        }
138
139        // Read the link, name and price from PayPal so the email matches the button.
140        $resource = PayPal_API_Client::get_resource_cached( $resource_id );
141        if ( is_wp_error( $resource ) ) {
142            // Pass on PayPal's status, as the REST endpoints do.
143            $error = PayPal_REST_Controller::api_error_to_rest_error( $resource );
144            wp_send_json_error(
145                array( 'message' => $error->get_error_message() ),
146                $error->get_error_data()['status'],
147                JSON_HEX_TAG | JSON_HEX_AMP
148            );
149        }
150
151        $link_attributes = PayPal_Attribute_Mapper::api_response_to_attributes( $resource );
152        $payment_link    = PayPal_Payment_Buttons::sanitize_paypal_script_url( $link_attributes['paymentLink'] ?? '' );
153        $product_name    = $link_attributes['productName'] ?? '';
154        $price           = PayPal_Payment_Buttons::link_price( $link_attributes );
155
156        if ( '' === $product_name ) {
157            $product_name = $resource_id;
158        }
159
160        if ( false === $payment_link || empty( $payment_link ) ) {
161            wp_send_json_error(
162                array( 'message' => __( 'Invalid or missing PayPal payment link.', 'jetpack-paypal-payments' ) ),
163                400,
164                JSON_HEX_TAG | JSON_HEX_AMP
165            );
166        }
167
168        // Email only a link with a price, on the product or its options.
169        if ( '' === $price ) {
170            wp_send_json_error(
171                array( 'message' => __( 'This payment link has no price.', 'jetpack-paypal-payments' ) ),
172                400,
173                JSON_HEX_TAG | JSON_HEX_AMP
174            );
175        }
176
177        // Rate counter uses a timestamped structure to avoid resetting the TTL
178        // on every increment (which would create a sliding window instead of
179        // a fixed window). The transient stores { count, window_start }.
180        if ( false === $rate_data || ! is_array( $rate_data ) ) {
181            set_transient(
182                $rate_key,
183                array(
184                    'count' => 1,
185                    'start' => time(),
186                ),
187                $rate_window
188            );
189        } else {
190            $rate_data['count'] = (int) $rate_data['count'] + 1;
191            // Don't reset TTL — calculate remaining time in the original window.
192            $elapsed   = time() - (int) $rate_data['start'];
193            $remaining = max( 1, $rate_window - $elapsed );
194            set_transient( $rate_key, $rate_data, $remaining );
195        }
196
197        // Daily counter. The TTL resets on each set_transient call, but this is
198        // acceptable because the key is date-scoped (includes Y-m-d) and
199        // auto-orphans on date rollover regardless of the exact TTL.
200        if ( false === $daily_data || ! is_array( $daily_data ) ) {
201            set_transient( $daily_key, array( 'count' => 1 ), DAY_IN_SECONDS );
202        } else {
203            $daily_data['count'] = (int) $daily_data['count'] + 1;
204            set_transient( $daily_key, $daily_data, DAY_IN_SECONDS );
205        }
206
207        // Build and send email.
208        $result = self::send_email( $recipient, $payment_link, $product_name, $price, $message );
209
210        if ( is_wp_error( $result ) ) {
211            wp_send_json_error(
212                array( 'message' => $result->get_error_message() ),
213                500,
214                JSON_HEX_TAG | JSON_HEX_AMP
215            );
216        }
217
218        // Log the send.
219        self::log_send( $resource_id, $recipient );
220
221        wp_send_json_success(
222            array(
223                'message' => sprintf(
224                    /* translators: %s: recipient email address */
225                    __( 'Payment link sent to %s.', 'jetpack-paypal-payments' ),
226                    $recipient
227                ),
228            ),
229            200,
230            JSON_HEX_TAG | JSON_HEX_AMP
231        );
232    }
233
234    /**
235     * Send the payment link email.
236     *
237     * @param string $recipient    Recipient email address.
238     * @param string $payment_link PayPal payment URL.
239     * @param string $product_name Product name.
240     * @param string $price        Formatted price, e.g. "From $29.99".
241     * @param string $message      Optional personal message from merchant.
242     * @return true|\WP_Error True on success, WP_Error on failure.
243     */
244    public static function send_email( $recipient, $payment_link, $product_name, $price, $message = '' ) {
245        $site_name = get_bloginfo( 'name' );
246
247        $subject = sprintf(
248            /* translators: 1: site name, 2: product name */
249            __( 'Payment link from %1$s: %2$s', 'jetpack-paypal-payments' ),
250            $site_name,
251            $product_name
252        );
253
254        $html_body = self::build_email_html( $site_name, $payment_link, $product_name, $price, $message );
255
256        // Temporarily set content type to HTML.
257        $set_html_content_type = function () {
258            return 'text/html';
259        };
260
261        add_filter( 'wp_mail_content_type', $set_html_content_type );
262
263        $sent = wp_mail( $recipient, $subject, $html_body );
264
265        // Remove the filter immediately.
266        remove_filter( 'wp_mail_content_type', $set_html_content_type );
267
268        if ( ! $sent ) {
269            return new \WP_Error(
270                'email_send_failed',
271                __( 'Failed to send email. Check your site\'s email configuration.', 'jetpack-paypal-payments' )
272            );
273        }
274
275        PayPal_Tracks::record_event( 'jetpack_paypal_email_sent', array( 'environment' => PayPal_OAuth::get_environment() ) );
276
277        return true;
278    }
279
280    /**
281     * Build the HTML email body.
282     *
283     * @param string $site_name    Site name.
284     * @param string $payment_link PayPal payment URL.
285     * @param string $product_name Product name.
286     * @param string $price        Formatted price.
287     * @param string $message      Optional personal message.
288     * @return string HTML email body.
289     */
290    private static function build_email_html( $site_name, $payment_link, $product_name, $price, $message ) {
291        // The emailed link goes straight to a buyer, so it carries the same
292        // attribution code as the rendered button.
293        $escaped_link  = esc_url( PayPal_Payment_Buttons::add_partner_attribution( $payment_link ) );
294        $escaped_name  = esc_html( $product_name );
295        $escaped_site  = esc_html( $site_name );
296        $escaped_price = esc_html( $price );
297
298        $message_html = '';
299        if ( ! empty( $message ) ) {
300            $message_html = sprintf(
301                '<p style="color:#555;font-size:14px;line-height:1.6;margin:16px 0;">%s</p>',
302                nl2br( esc_html( $message ) )
303            );
304        }
305
306        return '<!DOCTYPE html>
307<html>
308<head><meta charset="UTF-8"></head>
309<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">
310<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f5;padding:32px 16px;">
311<tr><td align="center">
312<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;">
313    <tr><td style="padding:32px 32px 24px;">
314        <h2 style="margin:0 0 4px;font-size:20px;color:#1e1e1e;">' . $escaped_name . '</h2>
315        <p style="margin:0;font-size:24px;font-weight:700;color:#003087;">' . $escaped_price . '</p>
316    </td></tr>
317    ' . ( $message_html ? '<tr><td style="padding:0 32px;">' . $message_html . '</td></tr>' : '' ) . '
318    <tr><td style="padding:16px 32px 32px;" align="center">
319        <a href="' . $escaped_link . '" style="display:inline-block;background:#FFC439;color:#003087;font-size:16px;font-weight:600;text-decoration:none;padding:12px 32px;border-radius:24px;">
320            ' . esc_html__( 'Pay with PayPal', 'jetpack-paypal-payments' ) . '
321        </a>
322    </td></tr>
323    <tr><td style="padding:0 32px 24px;text-align:center;">
324        <p style="margin:0;font-size:12px;color:#999;">
325            ' . sprintf(
326                /* translators: %s: site name */
327                esc_html__( 'Sent by %s via PayPal Payment Links', 'jetpack-paypal-payments' ),
328                $escaped_site
329            ) . '
330        </p>
331    </td></tr>
332</table>
333</td></tr>
334</table>
335</body>
336</html>';
337    }
338
339    /**
340     * Log a successful email send.
341     *
342     * @param string $resource_id PayPal resource ID.
343     * @param string $recipient   Recipient email address.
344     */
345    private static function log_send( $resource_id, $recipient ) {
346        $log = get_option( self::LOG_OPTION_KEY, array() );
347
348        if ( ! is_array( $log ) ) {
349            $log = array();
350        }
351
352        $log[] = array(
353            'resource_id' => $resource_id,
354            'email'       => self::mask_email( $recipient ),
355            'sent_at'     => gmdate( 'Y-m-d H:i:s' ),
356        );
357
358        // Cap at max entries.
359        if ( count( $log ) > self::MAX_LOG_ENTRIES ) {
360            $log = array_slice( $log, -self::MAX_LOG_ENTRIES );
361        }
362
363        update_option( self::LOG_OPTION_KEY, $log, false );
364    }
365
366    /**
367     * Mask an email address for privacy-safe storage.
368     *
369     * Stores only the first 3 characters of the local part + domain.
370     * Example: "customer@example.com" → "cus***@example.com"
371     *
372     * @param string $email Full email address.
373     * @return string Masked email address.
374     */
375    private static function mask_email( $email ) {
376        $parts = explode( '@', $email, 2 );
377        if ( count( $parts ) !== 2 ) {
378            return '***';
379        }
380        $local   = $parts[0];
381        $domain  = $parts[1];
382        $visible = min( 3, strlen( $local ) );
383        return substr( $local, 0, $visible ) . '***@' . $domain;
384    }
385
386    /**
387     * Get send log entries for a specific resource.
388     *
389     * @param string $resource_id PayPal resource ID.
390     * @return array Log entries for this resource.
391     */
392    public static function get_log_for_resource( $resource_id ) {
393        $log = get_option( self::LOG_OPTION_KEY, array() );
394
395        if ( ! is_array( $log ) ) {
396            return array();
397        }
398
399        return array_filter(
400            $log,
401            function ( $entry ) use ( $resource_id ) {
402                return isset( $entry['resource_id'] ) && $entry['resource_id'] === $resource_id;
403            }
404        );
405    }
406}