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