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