Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
88.64% covered (warning)
88.64%
312 / 352
64.29% covered (warning)
64.29%
9 / 14
CRAP
0.00% covered (danger)
0.00%
0 / 1
Feedback_Email_Renderer
88.64% covered (warning)
88.64%
312 / 352
64.29% covered (warning)
64.29%
9 / 14
78.40
0.00% covered (danger)
0.00%
0 / 1
 build_email_content
86.07% covered (warning)
86.07%
105 / 122
0.00% covered (danger)
0.00%
0 / 1
22.19
 build_test_submission_banner
0.00% covered (danger)
0.00%
0 / 5
0.00% covered (danger)
0.00%
0 / 1
2
 add_mark_as_spam_to_url
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
4.02
 get_compiled_form_for_email
42.86% covered (danger)
42.86%
12 / 28
0.00% covered (danger)
0.00%
0 / 1
19.94
 get_field_icon_name
100.00% covered (success)
100.00%
23 / 23
100.00% covered (success)
100.00%
1 / 1
1
 format_field_for_email
100.00% covered (success)
100.00%
31 / 31
100.00% covered (success)
100.00%
1 / 1
2
 wp_mail
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 get_mail_content_type
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_plain_text_alternative
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
 wrap_message_in_html_tags
100.00% covered (success)
100.00%
55 / 55
100.00% covered (success)
100.00%
1 / 1
3
 generate_respondent_info_html
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
13
 generate_metadata_html
96.88% covered (success)
96.88%
31 / 32
0.00% covered (danger)
0.00%
0 / 1
13
 generate_metadata_row
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
1
 minify_css
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
1
1<?php
2/**
3 * Feedback_Email_Renderer class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use Automattic\Jetpack\Forms\Dashboard\Dashboard as Forms_Dashboard;
11use Automattic\Jetpack\Forms\Jetpack_Forms;
12use Jetpack_Tracks_Event;
13use PHPMailer\PHPMailer\PHPMailer;
14
15/**
16 * Handles all email rendering for form submissions.
17 *
18 * Owns the pipeline from form data to HTML email: field compilation,
19 * sanitization, template wrapping, and email sending utilities.
20 */
21class Feedback_Email_Renderer {
22
23    /**
24     * The color of the respondent email link in the email header.
25     *
26     * @var string
27     */
28    public const TEXT_SECONDARY_COLOR = '#757575';
29
30    /**
31     * The color of the links in the email.
32     *
33     * @var string
34     */
35    public const LINK_COLOR = '#1e1e1e';
36
37    /**
38     * The color of the text in the email.
39     *
40     * @var string
41     */
42    public const TEXT_COLOR = '#1e1e1e';
43
44    /**
45     * Font size for field labels.
46     *
47     * @var string
48     */
49    public const FONT_SIZE_FIELD_LABEL = '15px';
50
51    /**
52     * Font size for field values, chips, and respondent name.
53     *
54     * @var string
55     */
56    public const FONT_SIZE_FIELD_VALUE = '16px';
57
58    /**
59     * Font size for metadata section and powered-by text.
60     *
61     * @var string
62     */
63    public const FONT_SIZE_METADATA = '13px';
64
65    /**
66     * Font size for action buttons and respondent email.
67     *
68     * @var string
69     */
70    public const FONT_SIZE_BUTTON = '14px';
71
72    /**
73     * Font size for small annotations like file sizes.
74     *
75     * @var string
76     */
77    public const FONT_SIZE_SMALL = '12px';
78
79    /**
80     * Build the complete email content for a form submission.
81     *
82     * Assembles the email title, compiled form fields, footer, actions,
83     * respondent info, metadata, and wraps everything in the HTML template.
84     *
85     * @param int          $post_id      The feedback post ID.
86     * @param Contact_Form $form         The form instance.
87     * @param Feedback     $response     The feedback response object.
88     * @param array        $context_data Context data with keys:
89     *   'time'                 => string  Formatted date/time string.
90     *   'url'                  => string  Source page URL.
91     *   'comment_author'       => string  Author name.
92     *   'comment_author_email' => string  Author email.
93     *   'comment_author_ip'    => string  Author IP address.
94     *   'is_spam'              => bool    Whether submission is spam.
95     *   'feedback_status'      => string  Post status of the feedback.
96     *
97     * @return array{title: string, message: string} The email title and rendered HTML message.
98     */
99    public static function build_email_content( $post_id, $form, $response, $context_data ) {
100        $time                 = $context_data['time'];
101        $url                  = $context_data['url'];
102        $comment_author       = $context_data['comment_author'];
103        $comment_author_email = $context_data['comment_author_email'];
104        $comment_author_ip    = $context_data['comment_author_ip'];
105        $is_spam              = $context_data['is_spam'];
106        $is_test              = ! empty( $context_data['is_test'] );
107        $feedback_status      = $context_data['feedback_status'];
108
109        /**
110         * Filter the title used in the response email.
111         *
112         * @module contact-form
113         *
114         * @since 0.18.0
115         *
116         * @param string the title of the email
117         */
118        $title   = (string) apply_filters( 'jetpack_forms_response_email_title', '' );
119        $title   = ! empty( $title ) ? sprintf( '<h1 class="email-header">%s</h1>', esc_html( $title ) ) : '';
120        $message = self::get_compiled_form_for_email( $post_id, $form );
121
122        if ( is_user_logged_in() ) {
123            $sent_by_text = sprintf(
124                // translators: the name of the site.
125                '<br />' . esc_html__( 'Sent by a verified %s user.', 'jetpack-forms' ) . '<br />',
126                isset( $GLOBALS['current_site']->site_name ) && $GLOBALS['current_site']->site_name ? $GLOBALS['current_site']->site_name : '"' . get_option( 'blogname' ) . '"'
127            );
128        } else {
129            $sent_by_text = '<br />' . esc_html__( 'Sent by an unverified visitor to your site.', 'jetpack-forms' ) . '<br />';
130        }
131
132        $footer_time = sprintf(
133            /* translators: Placeholder is the date and time when a form was submitted. */
134            esc_html__( 'Time: %1$s', 'jetpack-forms' ),
135            $time
136        );
137        $footer_ip = null;
138        if ( $comment_author_ip ) {
139            $ip_lookup_url               = sprintf( 'https://jetpack.com/redirect/?source=ip-lookup&path=%s', rawurlencode( $comment_author_ip ) );
140            $comment_author_ip_with_link = '<a href="' . esc_url( $ip_lookup_url ) . '">' . esc_html( $comment_author_ip ) . '</a>';
141            $comment_author_ip_with_flag = ( $response->get_country_flag() ? $response->get_country_flag() . ' ' : '' ) . $comment_author_ip_with_link;
142            $footer_ip                   = sprintf(
143                /* translators: Placeholder is the IP address of the person who submitted a form. */
144                esc_html__( 'IP Address: %1$s', 'jetpack-forms' ),
145                $comment_author_ip_with_flag
146            );
147        }
148        $footer_browser = null;
149        if ( $response->get_browser() ) {
150            $footer_browser = sprintf(
151                /* translators: Placeholder is the browser and platform used to submit a form. */
152                esc_html__( 'Browser: %1$s', 'jetpack-forms' ),
153                $response->get_browser()
154            ) . '<br />';
155        }
156
157        $footer_url = sprintf(
158            /* translators: Placeholder is the URL of the page where a form was submitted. */
159            __( 'Source URL: %1$s', 'jetpack-forms' ),
160            esc_url( $url )
161        );
162
163        // Get the status of the feedback.
164        $status = $is_spam ? 'spam' : 'inbox';
165
166        // Build the dashboard URL with the status and the feedback's post id if we have a post id.
167        $dashboard_url           = '';
168        $mark_as_spam_url        = '';
169        $footer_mark_as_spam_url = '';
170
171        /**
172         * Filters whether to show action buttons in notification emails.
173         *
174         * @module contact-form
175         *
176         * @since 7.20.0
177         *
178         * @param bool $show Whether to show the action buttons. Default true.
179         */
180        $show_email_actions = apply_filters( 'jetpack_forms_email_show_actions', true );
181
182        if ( $feedback_status !== 'jp-temp-feedback' && $show_email_actions ) {
183            $dashboard_url = Forms_Dashboard::get_forms_admin_url( $status, $post_id );
184            // Test responses don't get a Mark-as-spam link in the email â€” marking
185            // a test entry as spam from email is confusing and the form owner can
186            // always do it from the dashboard if they want.
187            if ( ! $is_test ) {
188                $mark_as_spam_url        = self::add_mark_as_spam_to_url( $dashboard_url );
189                $footer_mark_as_spam_url = sprintf(
190                    '<a href="%1$s">%2$s</a>',
191                    esc_url( $mark_as_spam_url ),
192                    __( 'Mark as spam', 'jetpack-forms' )
193                );
194            }
195        }
196
197        $footer = implode(
198            '',
199            /**
200             * Filter the footer used in the response email.
201             *
202             * @module contact-form
203             *
204             * @since 0.18.0
205             *
206             * @param array the lines of the footer, one line per array element.
207             */
208            apply_filters(
209                'jetpack_forms_response_email_footer',
210                array_filter(
211                    array(
212                        '<span style="font-size: ' . self::FONT_SIZE_SMALL . '">',
213                        $footer_time . '<br />',
214                        $footer_ip ? $footer_ip . '<br />' : null,
215                        $footer_browser ? $footer_browser . '<br />' : null,
216                        $footer_url . '<br /><br />',
217                        $footer_mark_as_spam_url ? $footer_mark_as_spam_url . '<br />' : null,
218                        $sent_by_text,
219                        '</span>',
220                    )
221                )
222            )
223        );
224
225        // Build the actions with both Mark as spam and View in dashboard buttons.
226        // Use fully table-based layout for maximum email client compatibility - no display:inline-block.
227        $actions = '';
228        if ( $dashboard_url ) {
229            if ( $is_test ) {
230                // For test submissions, only show the "View in dashboard" button
231                // centered â€” we deliberately drop the Mark-as-spam shortcut.
232                $actions = sprintf(
233                    '<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="button-table" align="center" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin: 0 auto;">
234                        <tr>
235                            <td class="button-cell" style="text-align: center; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
236                                <a href="%1$s" class="action-button action-button-primary" style="display: inline-block; background-color: #3858e9; color: #ffffff; border-radius: 4px; font-size: ' . self::FONT_SIZE_BUTTON . '; font-weight: 500; text-decoration: none; padding: 12px 24px; text-align: center; mso-padding-alt: 0;">%2$s</a>
237                            </td>
238                        </tr>
239                    </table>',
240                    esc_url( $dashboard_url ),
241                    __( 'View in dashboard', 'jetpack-forms' )
242                );
243            } else {
244                $actions = sprintf(
245                    '<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="button-table" align="center" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin: 0 auto;">
246                        <tr>
247                            <td class="button-cell" width="50%%" style="text-align: right; padding-right: 8px; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
248                                <a href="%1$s" class="action-button action-button-secondary" style="display: inline-block; background-color: transparent; color: %5$s; border: 1px solid #1e1e1e; border-radius: 4px; font-size: ' . self::FONT_SIZE_BUTTON . '; font-weight: 500; text-decoration: none; padding: 12px 24px; text-align: center; mso-padding-alt: 0;">%2$s</a>
249                            </td>
250                            <td class="button-cell" width="50%%" style="text-align: left; padding-left: 8px; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
251                                <a href="%3$s" class="action-button action-button-primary" style="display: inline-block; background-color: #3858e9; color: #ffffff; border-radius: 4px; font-size: ' . self::FONT_SIZE_BUTTON . '; font-weight: 500; text-decoration: none; padding: 12px 24px; text-align: center; mso-padding-alt: 0;">%4$s</a>
252                            </td>
253                        </tr>
254                    </table>',
255                    esc_url( $mark_as_spam_url ),
256                    __( 'Mark as spam', 'jetpack-forms' ),
257                    esc_url( $dashboard_url ),
258                    __( 'View in dashboard', 'jetpack-forms' ),
259                    self::LINK_COLOR
260                );
261            }
262        }
263
264        // Build respondent info for the new email template.
265        $respondent_info = array(
266            'name'   => $comment_author,
267            'email'  => $comment_author_email,
268            'avatar' => $response->get_author_avatar(),
269        );
270
271        // Get the form title for source metadata.
272        $form_title = $form->get_attribute( 'formTitle' );
273        if ( empty( $form_title ) && $form->current_post ) {
274            $form_title = Contact_Form::get_post_property( $form->current_post, 'post_title' );
275        }
276
277        // Test responses don't have a real source page; surface them as
278        // "Form preview" in the metadata table to match the dashboard.
279        if ( $is_test ) {
280            $source_label = __( 'Form preview', 'jetpack-forms' );
281            $source_url   = '';
282        } else {
283            $source_label = $form_title;
284            $source_url   = $url;
285        }
286
287        // Build metadata for the new email template.
288        $metadata = array(
289            'date'           => $time,
290            'source'         => $source_label,
291            'source_url'     => $source_url,
292            'device'         => $response->get_browser(),
293            'ip'             => $comment_author_ip,
294            'ip_flag'        => $response->get_country_flag(),
295            'logged_in_user' => $response->get_logged_in_user(),
296        );
297
298        /**
299         * Filters the message sent via email after a successful form submission.
300         *
301         * @module contact-form
302         *
303         * @since 1.3.1
304         *
305         * @param string $message Feedback email message.
306         * @param string $message Feedback email message as an array
307         */
308        $message = apply_filters( 'contact_form_message', implode( '', $message ), $message );
309
310        // Render a prominent TEST SUBMISSION banner when this came from a form
311        // preview, so the form owner can immediately tell that this response is
312        // a synthetic test. It is injected at the very top of the email so the
313        // rest of the body still looks like a normal submission email.
314        $banner = $is_test ? self::build_test_submission_banner() : '';
315
316        // This is called after `contact_form_message`, in order to preserve back-compat.
317        $message = self::wrap_message_in_html_tags( $title, $message, $footer, $actions, $respondent_info, $metadata, $banner );
318
319        return array(
320            'title'   => $title,
321            'message' => $message,
322        );
323    }
324
325    /**
326     * Build the HTML banner inserted at the top of a test-submission email body.
327     *
328     * Uses inline styles and a nested table layout for email-client compatibility.
329     * Mirrors the @wordpress/ui Notice component (warning intent): warm amber fill,
330     * 1px amber border with 8px radius, decorative info icon, 13px/20px body copy.
331     *
332     * @return string
333     */
334    private static function build_test_submission_banner() {
335        return sprintf(
336            '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%" class="test-submission-banner" style="border-collapse: collapse; margin: 0 0 24px 0;">
337                <tr>
338                    <td class="test-submission-banner-cell" style="padding: 12px; background-color: #fff7e0; border: 1px solid #d0b381; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif; color: #2e1900; font-size: 13px; line-height: 20px;">%s</td>
339                </tr>
340            </table>',
341            esc_html__( 'Test response via form preview.', 'jetpack-forms' )
342        );
343    }
344
345    /**
346     * Adds the mark_as_spam parameter to a dashboard URL.
347     *
348     * This method handles both legacy and wp-build dashboard URLs:
349     * - Legacy: appends &mark_as_spam to the hash fragment
350     * - WP-Build: adds mark_as_spam to the path inside the p parameter
351     *
352     * @param string $url The dashboard URL.
353     * @return string The URL with mark_as_spam parameter added.
354     */
355    private static function add_mark_as_spam_to_url( $url ) {
356        // Check if this is a wp-build URL (contains &p= parameter).
357        if ( strpos( $url, '&p=' ) !== false ) {
358            // WP-Build URL format: admin.php?page=jetpack-forms-responses-wp-admin&p=/responses/inbox?responseIds=["123"]
359            // We need to add &mark_as_spam=1 inside the p parameter path.
360            $parts = explode( '&p=', $url, 2 );
361
362            if ( count( $parts ) === 2 ) {
363                $base_url = $parts[0];
364                $path     = rawurldecode( $parts[1] );
365
366                // Add mark_as_spam parameter to the path.
367                $separator = strpos( $path, '?' ) !== false ? '&' : '?';
368                $path     .= $separator . 'mark_as_spam=1';
369
370                return $base_url . '&p=' . rawurlencode( $path );
371            }
372        }
373
374        // Legacy URL format: admin.php?page=jetpack-forms-admin#/responses?status=inbox&r=123
375        // Append &mark_as_spam to the hash fragment.
376        return $url . '&mark_as_spam';
377    }
378
379    /**
380     * Returns a compiled form with labels and values formatted for the email response
381     * in a form of an array of lines.
382     *
383     * @param int          $feedback_id - the feedback ID.
384     * @param Contact_Form $form - the form.
385     *
386     * @return array $lines
387     */
388    public static function get_compiled_form_for_email( $feedback_id, $form ) {
389        $compiled_form    = array();
390        $field_collection = array();
391        $response         = Feedback::get( $feedback_id );
392
393        if ( $response instanceof Feedback ) {
394            // Get both formats: 'all' for backward-compat filter, 'collection' for type-aware rendering.
395            $compiled_form    = $response->get_compiled_fields( 'email', 'all' );
396            $field_collection = $response->get_compiled_fields( 'email_html', 'collection' );
397        }
398
399        /**
400         * This filter allows a site owner to customize the response to be emailed, by adding their own HTML around it for example.
401         *
402         * @module contact-form
403         *
404         * @since 0.18.0
405         *
406         * @param array $compiled_form the form response to be filtered
407         * @param int $feedback_id the ID of the feedback form
408         * @param Contact_Form $form a copy of this object
409         */
410        $updated_compiled_form = apply_filters( 'jetpack_forms_response_email', $compiled_form, $feedback_id, $form );
411        if ( $updated_compiled_form !== $compiled_form ) {
412            // Filter was customized â€” use old rendering path for backward compat.
413            $compiled_form = $updated_compiled_form;
414            foreach ( $compiled_form as $key => $value ) {
415                if ( ! is_array( $value ) || ! isset( $value['label'] ) ) {
416                    continue;
417                }
418                $safe_display_label = Contact_Form::escape_and_sanitize_field_label( $value['label'] );
419                $safe_display_value = Contact_Form::escape_and_sanitize_field_value( $value['value'] );
420
421                if ( ! empty( $safe_display_label ) ) {
422                    $compiled_form[ $key ] = sprintf(
423                        '<p><strong>%1$s</strong><br /><span>%2$s</span></p>',
424                        Util::maybe_add_colon_to_label( $safe_display_label ),
425                        $safe_display_value
426                    );
427                } else {
428                    $compiled_form[ $key ] = sprintf(
429                        '<p><span>%s</span></p>',
430                        $safe_display_value
431                    );
432                }
433            }
434        } else {
435            // No filter customization â€” use new type-aware rendering.
436            $compiled_form = array();
437            foreach ( $field_collection as $field_data ) {
438                $compiled_form[] = self::format_field_for_email( $field_data );
439            }
440        }
441
442        return $compiled_form;
443    }
444
445    /**
446     * Get the icon name for a given field type.
447     *
448     * @param string $type The field type.
449     * @return string The icon name.
450     */
451    private static function get_field_icon_name( $type ) {
452        $map = array(
453            'text'              => 'field-text',
454            'name'              => 'field-text',
455            'email'             => 'field-email',
456            'textarea'          => 'field-textarea',
457            'select'            => 'field-select',
458            'radio'             => 'field-single-choice',
459            'checkbox'          => 'field-checkbox',
460            'checkbox-multiple' => 'field-multiple-choice',
461            'phone'             => 'field-telephone',
462            'telephone'         => 'field-telephone',
463            'number'            => 'field-number',
464            'slider'            => 'field-slider',
465            'date'              => 'field-date',
466            'time'              => 'field-time',
467            'url'               => 'field-url',
468            'rating'            => 'field-rating',
469            'image-select'      => 'field-image-select',
470            'file'              => 'field-file',
471            'consent'           => 'field-consent',
472            'hidden'            => 'field-hidden',
473        );
474        return $map[ $type ] ?? 'field-text';
475    }
476
477    /**
478     * Format a single field for the email notification using type-aware rendering.
479     *
480     * Takes a collection item from get_compiled_fields( 'email', 'collection' )
481     * and produces a table row with an icon, label, and type-specific value.
482     *
483     * @param array $field_data Field data with keys: label, value, type, id, key, meta.
484     * @return string HTML for the field row.
485     */
486    private static function format_field_for_email( $field_data ) {
487        $label = $field_data['label'] ?? '';
488        $value = $field_data['value'] ?? '';
489        $type  = $field_data['type'] ?? 'text';
490
491        $safe_label = Contact_Form::escape_and_sanitize_field_label( $label );
492        $icon_name  = self::get_field_icon_name( $type );
493        $icon_url   = Jetpack_Forms::plugin_url() . 'contact-form/images/field-icons/' . $icon_name . '@2x.png';
494
495        // Value is already rendered as HTML by Feedback_Field::get_render_email_html_value().
496        $rendered_value = $value;
497
498        // Build the field row as a table with icon + content.
499        $html  = '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-bottom: 1px solid #F0F0F0; padding: 0; margin: 0;">';
500        $html .= '<tr>';
501        $html .= '<td class="field-icon-cell" width="24" valign="top" style="padding: 18px 16px 20px 0; width: 24px; vertical-align: top; -webkit-user-select: none; user-select: none;">';
502        $html .= sprintf(
503            '<img src="%s" width="24" height="24" alt="" style="display: block; width: 24px; height: 24px; -webkit-user-select: none; user-select: none;" />',
504            esc_url( $icon_url )
505        );
506        $html .= '</td>';
507        $html .= '<td valign="top" style="padding: 20px 0;">';
508        if ( ! empty( $safe_label ) ) {
509            $html .= sprintf(
510                '<div style="font-size: ' . self::FONT_SIZE_FIELD_LABEL . '; color: %s; line-height: 1.4; margin-bottom: 8px;">%s</div>',
511                self::TEXT_SECONDARY_COLOR,
512                esc_html( $safe_label )
513            );
514        }
515        $html .= sprintf(
516            '<div style="font-size: ' . self::FONT_SIZE_FIELD_VALUE . '; color: %s; line-height: 1.5;">%s</div>',
517            self::TEXT_COLOR,
518            $rendered_value
519        );
520        $html .= '</td>';
521        $html .= '</tr>';
522        $html .= '</table>';
523
524        return $html;
525    }
526
527    /**
528     * Wrapper for wp_mail() that enables HTML messages with text alternatives
529     *
530     * @param string|array $to          Array or comma-separated list of email addresses to send message.
531     * @param string       $subject     Email subject.
532     * @param string       $message     Message contents.
533     * @param string|array $headers     Optional. Additional headers.
534     * @param string|array $attachments Optional. Files to attach.
535     *
536     * @return bool Whether the email contents were sent successfully.
537     */
538    public static function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
539        add_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
540        add_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
541
542        $result = \wp_mail( $to, $subject, $message, $headers, $attachments );
543
544        remove_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
545        remove_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
546
547        return $result;
548    }
549
550    /**
551     * Get the content type that should be assigned to outbound emails
552     *
553     * @return string
554     */
555    public static function get_mail_content_type() {
556        return 'text/html';
557    }
558
559    /**
560     * Add a plain-text alternative part to an outbound email
561     *
562     * This makes the message more accessible to mail clients that aren't HTML-aware, and decreases the likelihood
563     * that the message will be flagged as spam.
564     *
565     * @param PHPMailer $phpmailer - the phpmailer.
566     */
567    public static function add_plain_text_alternative( $phpmailer ) {
568        // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
569
570        // Remove the preheader (hidden email preview text) so it doesn't duplicate the title in plain text.
571        $alt_body = preg_replace( '/<span class="preheader">.*?<\/span>/s', '', $phpmailer->Body );
572
573        // Add an extra break so that the extra space above the <p> is preserved after the <p> is stripped out.
574        $alt_body = str_replace( '<p>', '<p><br />', $alt_body );
575
576        // Convert <br> to \n breaks, to preserve the space between lines that we want to keep.
577        $alt_body = str_replace( array( '<br>', '<br />' ), "\n", $alt_body );
578
579        // Convert <div> to \n breaks, to preserve space between lines for new email formatting.
580        $alt_body = str_replace( '<div', "\n<div", $alt_body );
581
582        // Convert <hr> to an plain-text equivalent, to preserve the integrity of the message.
583        $alt_body = str_replace( array( '<hr>', '<hr />' ), "----\n", $alt_body );
584
585        // Trim the plain text message to remove the \n breaks that were after <doctype>, <html>, and <body>.
586        $phpmailer->AltBody = trim( wp_strip_all_tags( $alt_body ) );
587        // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
588    }
589
590    /**
591     * Wrap a message body with the appropriate in HTML tags
592     *
593     * This helps to ensure correct parsing by clients, and also helps avoid triggering spam filtering rules
594     *
595     * @param string $title - title of the email.
596     * @param string $body - the message body.
597     * @param string $footer - the footer containing meta information.
598     * @param string $actions - HTML for actions displayed in the email.
599     * @param array  $respondent_info - Optional. Respondent information array with 'name', 'email', 'avatar'.
600     * @param array  $metadata - Optional. Metadata array with 'date', 'source', 'source_url', 'device', 'ip', 'ip_flag', 'logged_in_user' (with display_name, username, id).
601     * @param string $banner - Optional. HTML banner inserted at the very top of the email body (above the title).
602     *
603     * @return string
604     */
605    public static function wrap_message_in_html_tags( $title, $body, $footer, $actions = '', $respondent_info = array(), $metadata = array(), $banner = '' ) {
606        // Don't do anything if the message was already wrapped in HTML tags
607        // That could have be done by a plugin via filters.
608        if ( str_contains( $body, '<html' ) ) {
609            return $body;
610        }
611
612        $template = '';
613        $style    = '';
614
615        // The hash is just used to anonymize the admin email and have a unique identifier for the event.
616        // The secret key used could have been a random string, but it's better to use the version number to make it easier to track.
617        $event = new Jetpack_Tracks_Event(
618            (object) array(
619                '_en' => 'jetpack_forms_email_open',
620                '_ui' => hash_hmac( 'md5', get_option( 'admin_email' ), JETPACK__VERSION ),
621                '_ut' => 'anon',
622            )
623        );
624
625        $tracking_pixel = '<img src="' . $event->build_pixel_url() . '" alt="" width="1" height="1" />';
626
627        /**
628         * Filter the filename of the template HTML surrounding the response email. The PHP file will return the template in a variable called $template.
629         *
630         * @module contact-form
631         *
632         * @since 0.18.0
633         *
634         * @param string the filename of the HTML template used for response emails to the form owner.
635         */
636        $print_style = null; // May be set by the template file loaded below.
637        require apply_filters( 'jetpack_forms_response_email_template', __DIR__ . '/templates/email-response.php' );
638
639        /**
640         * Filter the HTML for the powered by section in the email.
641         *
642         * @module contact-form
643         *
644         * @since 7.2.0
645         *
646         * @param string $powered_by_html The HTML for the powered by section in the email.
647         */
648        // Use table-based layout for maximum email client compatibility.
649        $logo_url        = Jetpack_Forms::plugin_url() . 'contact-form/images/field-icons/jetpack-logo@2x.png';
650        $powered_by_html = apply_filters(
651            'jetpack_forms_email_powered_by_html',
652            str_replace(
653                "\t",
654                '',
655                '
656                <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" class="powered-by-table" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin-top: 24px;">
657                    <tr>
658                        <td align="center" class="powered-by" style="padding: 24px 0 0 0; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
659                            <img src="' . esc_url( $logo_url ) . '" alt="Jetpack" width="20" height="20" style="vertical-align: middle; margin-right: 6px; border: 0; outline: none; text-decoration: none; -webkit-user-select: none; user-select: none;">
660                            <span style="font-size: ' . self::FONT_SIZE_METADATA . '; color: #50575e; line-height: 20px;">' .
661                    sprintf(
662                        // translators: %1$s is a link to the Jetpack Forms page.
663                        __( 'Powered by %1$s', 'jetpack-forms' ),
664                        '<a href="https://jetpack.com/forms/?utm_source=jetpack-forms&utm_medium=email&utm_campaign=form-submissions" style="font-size: ' . self::FONT_SIZE_METADATA . '; color: #50575e; text-decoration: none;">Jetpack Forms</a>'
665                    ) . '</span>
666                        </td>
667                    </tr>
668                </table>'
669            )
670        );
671
672        // Generate respondent info HTML.
673        $respondent_html = self::generate_respondent_info_html( $respondent_info );
674
675        // Generate metadata HTML.
676        $metadata_html = self::generate_metadata_html( $metadata );
677
678        // Minify CSS to stay under Gmail's 8,192-char limit for style blocks.
679        // The template file keeps readable formatting; we strip it here at render time.
680        $style = self::minify_css( $style );
681
682        $html_message = sprintf(
683            // The tabs are just here so that the raw code is correctly formatted for developers
684            // They're removed so that they don't affect the final message sent to users.
685            str_replace(
686                "\t",
687                '',
688                $template
689            ),
690            $title,
691            $body,
692            '',
693            '',
694            $footer,
695            $style,
696            $tracking_pixel,
697            $actions,
698            $powered_by_html,
699            $respondent_html,
700            $metadata_html,
701            $banner
702        );
703
704        // Inject print styles into <body> for Outlook.com compatibility (it strips <head> styles
705        // but preserves <body> styles). The same styles are already in <head> for Gmail and others.
706        // This is done after sprintf to avoid % signs in CSS being interpreted as format specifiers.
707        // @phan-suppress-next-line PhanRedundantCondition -- $print_style is set by the template file loaded via require above.
708        if ( ! empty( $print_style ) ) {
709            $html_message = str_replace( '</body>', '<style type="text/css">' . $print_style . '</style></body>', $html_message );
710        }
711
712        return $html_message;
713    }
714
715    /**
716     * Generate HTML for respondent info section in email.
717     *
718     * @param array $respondent_info Array with 'name', 'email', 'avatar' keys.
719     * @return string HTML for respondent info section.
720     */
721    private static function generate_respondent_info_html( $respondent_info ) {
722        if ( empty( $respondent_info ) ) {
723            return '';
724        }
725
726        $name   = isset( $respondent_info['name'] ) ? esc_html( $respondent_info['name'] ) : '';
727        $email  = isset( $respondent_info['email'] ) ? esc_html( $respondent_info['email'] ) : '';
728        $avatar = isset( $respondent_info['avatar'] ) ? esc_url( $respondent_info['avatar'] ) : '';
729
730        // Don't show section if there's no name or email.
731        if ( empty( $name ) && empty( $email ) ) {
732            return '';
733        }
734
735        // Get initials for avatar fallback.
736        $initials = '';
737        if ( ! empty( $name ) ) {
738            $name_parts = explode( ' ', $name );
739            $initials   = strtoupper( substr( $name_parts[0], 0, 1 ) );
740            if ( count( $name_parts ) > 1 ) {
741                $initials .= strtoupper( substr( end( $name_parts ), 0, 1 ) );
742            }
743        } elseif ( ! empty( $email ) ) {
744            $initials = strtoupper( substr( $email, 0, 1 ) );
745        }
746
747        // Avatar content - either image or initials.
748        $avatar_content = ! empty( $avatar )
749            ? '<img src="' . $avatar . '" alt="" width="48" height="48" style="border-radius: 24px; vertical-align: middle;">'
750            : esc_html( $initials );
751
752        // Use table layout for maximum email client compatibility.
753        $html = '
754        <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="respondent-table" width="100%" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin-bottom: 16px;">
755            <tr>
756                <td class="respondent-avatar-cell" style="width: 64px; vertical-align: middle; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
757                    <!--[if mso]>
758                    <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="48" height="48" style="width: 48px; height: 48px;">
759                    <tr>
760                    <td align="center" valign="middle" style="width: 48px; height: 48px; background-color: #f0f0f0; border-radius: 24px; font-size: 18px; font-weight: 600; color: #50575e;">
761                    <![endif]-->
762                    <div class="respondent-avatar-wrapper" style="width: 48px; height: 48px; border-radius: 24px; background-color: #f0f0f0; text-align: center; line-height: 48px; font-size: 18px; font-weight: 600; color: #50575e;">
763                        ' . $avatar_content . '
764                    </div>
765                    <!--[if mso]>
766                    </td>
767                    </tr>
768                    </table>
769                    <![endif]-->
770                </td>
771                <td class="respondent-details-cell" style="vertical-align: middle; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
772                    ' . ( ! empty( $name ) ? '<div class="respondent-name" style="font-size: ' . self::FONT_SIZE_FIELD_VALUE . '; font-weight: 600; color: ' . self::TEXT_COLOR . '; margin: 0 0 2px 0; line-height: 1.4;">' . $name . '</div>' : '' ) . '
773                    ' . ( ! empty( $email ) ? '<div class="respondent-email" style="font-size: ' . self::FONT_SIZE_BUTTON . '; margin: 0; line-height: 1.4;"><a href="mailto:' . $email . '" style="color: ' . self::TEXT_SECONDARY_COLOR . '; text-decoration: underline;">' . $email . '</a></div>' : '' ) . '
774                </td>
775            </tr>
776        </table>';
777
778        return str_replace( "\t", '', $html );
779    }
780
781    /**
782     * Generate HTML for metadata section in email.
783     *
784     * @param array $metadata Array with 'date', 'source', 'source_url', 'device', 'ip', 'ip_flag', 'logged_in_user' (with display_name, username, id) keys.
785     * @return string HTML for metadata section.
786     */
787    private static function generate_metadata_html( $metadata ) {
788        if ( empty( $metadata ) ) {
789            return '';
790        }
791
792        $rows = array();
793
794        // Date row.
795        if ( ! empty( $metadata['date'] ) ) {
796            $rows[] = self::generate_metadata_row( __( 'Date', 'jetpack-forms' ), esc_html( $metadata['date'] ) );
797        }
798
799        // Source row.
800        if ( ! empty( $metadata['source'] ) ) {
801            $source_value = esc_html( $metadata['source'] );
802            if ( ! empty( $metadata['source_url'] ) ) {
803                $source_value = '<a href="' . esc_url( $metadata['source_url'] ) . '" style="color: ' . self::LINK_COLOR . '; text-decoration: underline;">' . $source_value . '</a>';
804            }
805            $rows[] = self::generate_metadata_row( __( 'Source', 'jetpack-forms' ), $source_value );
806        }
807
808        // Device row.
809        if ( ! empty( $metadata['device'] ) ) {
810            $rows[] = self::generate_metadata_row( __( 'Device', 'jetpack-forms' ), esc_html( $metadata['device'] ) );
811        }
812
813        // IP Address row.
814        if ( ! empty( $metadata['ip'] ) ) {
815            $ip_value = '';
816            if ( ! empty( $metadata['ip_flag'] ) ) {
817                $ip_value .= $metadata['ip_flag'] . ' ';
818            }
819            $ip_value .= esc_html( $metadata['ip'] );
820            $rows[]    = self::generate_metadata_row( __( 'IP address', 'jetpack-forms' ), $ip_value );
821        }
822
823        // Logged in user row.
824        if ( ! empty( $metadata['logged_in_user'] ) && isset( $metadata['logged_in_user']['id'] ) ) {
825            $user_id    = $metadata['logged_in_user']['id'];
826            $user_value = '#' . $user_id;
827            if ( ! empty( $metadata['logged_in_user']['display_name'] ) ) {
828                $user_value = $metadata['logged_in_user']['display_name'] . ' (#' . $user_id . ')';
829            } elseif ( ! empty( $metadata['logged_in_user']['username'] ) ) {
830                $user_value = $metadata['logged_in_user']['username'] . ' (#' . $user_id . ')';
831            }
832            $rows[] = self::generate_metadata_row( __( 'Logged-in user', 'jetpack-forms' ), esc_html( $user_value ) );
833        }
834
835        if ( empty( $rows ) ) {
836            return '';
837        }
838
839        // Use table layout for maximum email client compatibility.
840        $html = '
841        <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="metadata-table" width="100%" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; margin-bottom: 24px;">
842            ' . implode( '', $rows ) . '
843            <tr><td colspan="2" style="padding: 24px 0 0 0; border-bottom: 1px solid #E4E4E7; font-size: 0; line-height: 0;">&nbsp;</td></tr>
844        </table>';
845
846        return str_replace( "\t", '', $html );
847    }
848
849    /**
850     * Generate a single metadata row.
851     *
852     * @param string $label The label text.
853     * @param string $value The value (can contain HTML).
854     * @return string HTML for the row.
855     */
856    private static function generate_metadata_row( $label, $value ) {
857        return '
858            <tr>
859                <td class="metadata-label" style="color: #50575e; width: 100px; padding: 4px 12px 4px 0; font-size: ' . self::FONT_SIZE_METADATA . '; vertical-align: top; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif; line-height: 1.4;">' . esc_html( $label ) . ':</td>
860                <td class="metadata-value" style="color: #1e1e1e; padding: 4px 0; font-size: ' . self::FONT_SIZE_METADATA . '; vertical-align: top; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif; line-height: 1.4;">' . $value . '</td>
861            </tr>';
862    }
863
864    /**
865     * Minify a CSS string by removing comments, collapsing whitespace,
866     * and stripping unnecessary characters.
867     *
868     * Gmail imposes an 8,192-character limit across all <style> blocks.
869     * This keeps the template file readable while fitting under the limit.
870     *
871     * @param string $css The CSS string (may include <style> tags).
872     * @return string The minified CSS string.
873     */
874    private static function minify_css( $css ) {
875        // Remove CSS comments.
876        $css = preg_replace( '/\/\*.*?\*\//s', '', $css );
877        // Collapse all whitespace (tabs, newlines, spaces) into single spaces.
878        $css = preg_replace( '/\s+/', ' ', $css );
879        // Remove spaces around CSS punctuation: { } ; : ,
880        $css = preg_replace( '/\s*([{};,])\s*/', '$1', $css );
881        // Remove space after colons in all contexts.
882        $css = preg_replace( '/:\s+/', ':', $css );
883        // Remove trailing semicolons before closing braces.
884        $css = str_replace( ';}', '}', $css );
885        return trim( $css );
886    }
887}