Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
32.12% covered (danger)
32.12%
53 / 165
50.00% covered (danger)
50.00%
4 / 8
CRAP
0.00% covered (danger)
0.00%
0 / 1
PayPal_Email_Sender
32.52% covered (danger)
32.52%
53 / 163
50.00% covered (danger)
50.00%
4 / 8
531.74
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
0.00% covered (danger)
0.00%
0 / 91
0.00% covered (danger)
0.00%
0 / 1
600
 send_email
100.00% covered (success)
100.00%
19 / 19
100.00% covered (success)
100.00%
1 / 1
2
 build_email_html
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
3
 log_send
0.00% covered (danger)
0.00%
0 / 11
0.00% covered (danger)
0.00%
0 / 1
12
 mask_email
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
6
 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 $$next-version$$
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        $payment_link = isset( $_POST['payment_link'] ) ? esc_url_raw( wp_unslash( $_POST['payment_link'] ) ) : '';
90        $payment_link = PayPal_Payment_Buttons::sanitize_paypal_script_url( $payment_link );
91        $product_name = isset( $_POST['product_name'] ) ? sanitize_text_field( wp_unslash( $_POST['product_name'] ) ) : '';
92        $price        = isset( $_POST['price'] ) ? sanitize_text_field( wp_unslash( $_POST['price'] ) ) : '';
93        $currency     = isset( $_POST['currency'] ) ? sanitize_text_field( wp_unslash( $_POST['currency'] ) ) : 'USD';
94        $message      = isset( $_POST['message'] ) ? sanitize_textarea_field( wp_unslash( $_POST['message'] ) ) : '';
95        $resource_id  = isset( $_POST['resource_id'] ) ? sanitize_text_field( wp_unslash( $_POST['resource_id'] ) ) : '';
96
97        if ( ! is_email( $recipient ) ) {
98            wp_send_json_error(
99                array( 'message' => __( 'Please enter a valid email address.', 'jetpack-paypal-payments' ) ),
100                400,
101                JSON_HEX_TAG | JSON_HEX_AMP
102            );
103        }
104
105        if ( false === $payment_link || empty( $payment_link ) ) {
106            wp_send_json_error(
107                array( 'message' => __( 'Invalid or missing PayPal payment link.', 'jetpack-paypal-payments' ) ),
108                400,
109                JSON_HEX_TAG | JSON_HEX_AMP
110            );
111        }
112
113        // Rate limiting: max 10 sends per 60-second window + 50/day cap per user.
114        $user_id     = get_current_user_id();
115        $rate_key    = 'paypal_email_rate_' . $user_id;
116        $daily_key   = 'paypal_email_daily_' . $user_id . '_' . gmdate( 'Y-m-d' );
117        $rate_data   = get_transient( $rate_key );
118        $rate_count  = is_array( $rate_data ) ? (int) $rate_data['count']
119            : ( is_numeric( $rate_data ) ? (int) $rate_data : 0 );
120        $daily_data  = get_transient( $daily_key );
121        $daily_count = is_array( $daily_data ) ? (int) $daily_data['count']
122            : ( is_numeric( $daily_data ) ? (int) $daily_data : 0 );
123        $rate_limit  = 10;
124        $rate_window = 60; // seconds.
125        $daily_limit = 50;
126
127        if ( $rate_count >= $rate_limit ) {
128            wp_send_json_error(
129                array( 'message' => __( 'Rate limit exceeded. Please wait a minute before sending more emails.', 'jetpack-paypal-payments' ) ),
130                429,
131                JSON_HEX_TAG | JSON_HEX_AMP
132            );
133        }
134
135        if ( $daily_count >= $daily_limit ) {
136            wp_send_json_error(
137                array( 'message' => __( 'Daily email limit reached. Please try again tomorrow.', 'jetpack-paypal-payments' ) ),
138                429,
139                JSON_HEX_TAG | JSON_HEX_AMP
140            );
141        }
142
143        // Rate counter uses a timestamped structure to avoid resetting the TTL
144        // on every increment (which would create a sliding window instead of
145        // a fixed window). The transient stores { count, window_start }.
146        if ( false === $rate_data || ! is_array( $rate_data ) ) {
147            set_transient(
148                $rate_key,
149                array(
150                    'count' => 1,
151                    'start' => time(),
152                ),
153                $rate_window
154            );
155        } else {
156            $rate_data['count'] = (int) $rate_data['count'] + 1;
157            // Don't reset TTL — calculate remaining time in the original window.
158            $elapsed   = time() - (int) $rate_data['start'];
159            $remaining = max( 1, $rate_window - $elapsed );
160            set_transient( $rate_key, $rate_data, $remaining );
161        }
162
163        // Daily counter. The TTL resets on each set_transient call, but this is
164        // acceptable because the key is date-scoped (includes Y-m-d) and
165        // auto-orphans on date rollover regardless of the exact TTL.
166        if ( false === $daily_data || ! is_array( $daily_data ) ) {
167            set_transient( $daily_key, array( 'count' => 1 ), DAY_IN_SECONDS );
168        } else {
169            $daily_data['count'] = (int) $daily_data['count'] + 1;
170            set_transient( $daily_key, $daily_data, DAY_IN_SECONDS );
171        }
172
173        // Build and send email.
174        $result = self::send_email( $recipient, $payment_link, $product_name, $price, $currency, $message );
175
176        if ( is_wp_error( $result ) ) {
177            wp_send_json_error(
178                array( 'message' => $result->get_error_message() ),
179                500,
180                JSON_HEX_TAG | JSON_HEX_AMP
181            );
182        }
183
184        // Log the send.
185        self::log_send( $resource_id, $recipient );
186
187        wp_send_json_success(
188            array(
189                'message' => sprintf(
190                    /* translators: %s: recipient email address */
191                    __( 'Payment link sent to %s.', 'jetpack-paypal-payments' ),
192                    $recipient
193                ),
194            ),
195            200,
196            JSON_HEX_TAG | JSON_HEX_AMP
197        );
198    }
199
200    /**
201     * Send the payment link email.
202     *
203     * @param string $recipient    Recipient email address.
204     * @param string $payment_link PayPal payment URL.
205     * @param string $product_name Product name.
206     * @param string $price        Price value.
207     * @param string $currency     Currency code.
208     * @param string $message      Optional personal message from merchant.
209     * @return true|\WP_Error True on success, WP_Error on failure.
210     */
211    public static function send_email( $recipient, $payment_link, $product_name, $price, $currency, $message = '' ) {
212        $site_name = get_bloginfo( 'name' );
213
214        $subject = sprintf(
215            /* translators: 1: site name, 2: product name */
216            __( 'Payment link from %1$s: %2$s', 'jetpack-paypal-payments' ),
217            $site_name,
218            $product_name
219        );
220
221        $html_body = self::build_email_html( $site_name, $payment_link, $product_name, $price, $currency, $message );
222
223        // Temporarily set content type to HTML.
224        $set_html_content_type = function () {
225            return 'text/html';
226        };
227
228        add_filter( 'wp_mail_content_type', $set_html_content_type );
229
230        $sent = wp_mail( $recipient, $subject, $html_body );
231
232        // Remove the filter immediately.
233        remove_filter( 'wp_mail_content_type', $set_html_content_type );
234
235        if ( ! $sent ) {
236            return new \WP_Error(
237                'email_send_failed',
238                __( 'Failed to send email. Check your site\'s email configuration.', 'jetpack-paypal-payments' )
239            );
240        }
241
242        return true;
243    }
244
245    /**
246     * Build the HTML email body.
247     *
248     * @param string $site_name    Site name.
249     * @param string $payment_link PayPal payment URL.
250     * @param string $product_name Product name.
251     * @param string $price        Price value.
252     * @param string $currency     Currency code.
253     * @param string $message      Optional personal message.
254     * @return string HTML email body.
255     */
256    private static function build_email_html( $site_name, $payment_link, $product_name, $price, $currency, $message ) {
257        $formatted_price = PayPal_Payment_Buttons::format_price( $price, $currency );
258        // The emailed link goes straight to a buyer, so it carries the same
259        // attribution code as the rendered button.
260        $escaped_link  = esc_url( PayPal_Payment_Buttons::add_partner_attribution( $payment_link ) );
261        $escaped_name  = esc_html( $product_name );
262        $escaped_site  = esc_html( $site_name );
263        $escaped_price = esc_html( $formatted_price );
264
265        $message_html = '';
266        if ( ! empty( $message ) ) {
267            $message_html = sprintf(
268                '<p style="color:#555;font-size:14px;line-height:1.6;margin:16px 0;">%s</p>',
269                nl2br( esc_html( $message ) )
270            );
271        }
272
273        return '<!DOCTYPE html>
274<html>
275<head><meta charset="UTF-8"></head>
276<body style="margin:0;padding:0;background:#f5f5f5;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;">
277<table width="100%" cellpadding="0" cellspacing="0" style="background:#f5f5f5;padding:32px 16px;">
278<tr><td align="center">
279<table width="560" cellpadding="0" cellspacing="0" style="background:#fff;border-radius:8px;overflow:hidden;">
280    <tr><td style="padding:32px 32px 24px;">
281        <h2 style="margin:0 0 4px;font-size:20px;color:#1e1e1e;">' . $escaped_name . '</h2>
282        <p style="margin:0;font-size:24px;font-weight:700;color:#003087;">' . $escaped_price . '</p>
283    </td></tr>
284    ' . ( $message_html ? '<tr><td style="padding:0 32px;">' . $message_html . '</td></tr>' : '' ) . '
285    <tr><td style="padding:16px 32px 32px;" align="center">
286        <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;">
287            ' . esc_html__( 'Pay with PayPal', 'jetpack-paypal-payments' ) . '
288        </a>
289    </td></tr>
290    <tr><td style="padding:0 32px 24px;text-align:center;">
291        <p style="margin:0;font-size:12px;color:#999;">
292            ' . sprintf(
293                /* translators: %s: site name */
294                esc_html__( 'Sent by %s via PayPal Payment Links', 'jetpack-paypal-payments' ),
295                $escaped_site
296            ) . '
297        </p>
298    </td></tr>
299</table>
300</td></tr>
301</table>
302</body>
303</html>';
304    }
305
306    /**
307     * Log a successful email send.
308     *
309     * @param string $resource_id PayPal resource ID.
310     * @param string $recipient   Recipient email address.
311     */
312    private static function log_send( $resource_id, $recipient ) {
313        $log = get_option( self::LOG_OPTION_KEY, array() );
314
315        if ( ! is_array( $log ) ) {
316            $log = array();
317        }
318
319        $log[] = array(
320            'resource_id' => $resource_id,
321            'email'       => self::mask_email( $recipient ),
322            'sent_at'     => gmdate( 'Y-m-d H:i:s' ),
323        );
324
325        // Cap at max entries.
326        if ( count( $log ) > self::MAX_LOG_ENTRIES ) {
327            $log = array_slice( $log, -self::MAX_LOG_ENTRIES );
328        }
329
330        update_option( self::LOG_OPTION_KEY, $log, false );
331    }
332
333    /**
334     * Mask an email address for privacy-safe storage.
335     *
336     * Stores only the first 3 characters of the local part + domain.
337     * Example: "customer@example.com" → "cus***@example.com"
338     *
339     * @param string $email Full email address.
340     * @return string Masked email address.
341     */
342    private static function mask_email( $email ) {
343        $parts = explode( '@', $email, 2 );
344        if ( count( $parts ) !== 2 ) {
345            return '***';
346        }
347        $local   = $parts[0];
348        $domain  = $parts[1];
349        $visible = min( 3, strlen( $local ) );
350        return substr( $local, 0, $visible ) . '***@' . $domain;
351    }
352
353    /**
354     * Get send log entries for a specific resource.
355     *
356     * @param string $resource_id PayPal resource ID.
357     * @return array Log entries for this resource.
358     */
359    public static function get_log_for_resource( $resource_id ) {
360        $log = get_option( self::LOG_OPTION_KEY, array() );
361
362        if ( ! is_array( $log ) ) {
363            return array();
364        }
365
366        return array_filter(
367            $log,
368            function ( $entry ) use ( $resource_id ) {
369                return isset( $entry['resource_id'] ) && $entry['resource_id'] === $resource_id;
370            }
371        );
372    }
373}