PluginProbe
Jetpack – WP Security, Backup, Speed, & Growth / 16.3
Jetpack – WP Security, Backup, Speed, & Growth v16.3
16.3 16.3-beta 16.3-a.5 16.3-a.7 16.3-a.3 16.3-a.1 16.2 16.2-beta 12.0.3 12.1.3 12.2.3 12.3.2 12.4.2 12.5.2 12.6.4 12.7.3 12.8.3 12.9.5 13.0.2 13.1.5 13.2.4 13.3.3 13.4.5 13.5.2 13.6.2 All 508 releases
jetpack / jetpack_vendor / automattic / jetpack-forms / src / contact-form / class-feedback-email-renderer.php

class-feedback-email-renderer.php in Jetpack – WP Security, Backup, Speed, & Growth 16.3, at jetpack_vendor/automattic/jetpack-forms/src/contact-form/class-feedback-email-renderer.php

926 lines 37.8 KB
No matching file
Up and down to move Enter to open Esc to close
Raw Download Zip
1 <?php
2 /**
3 * Feedback_Email_Renderer class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8 namespace Automattic\Jetpack\Forms\ContactForm;
9
10 use Automattic\Jetpack\Connection\Manager;
11 use Automattic\Jetpack\Forms\Dashboard\Dashboard as Forms_Dashboard;
12 use Automattic\Jetpack\Forms\Jetpack_Forms;
13 use Jetpack_Tracks_Event;
14 use 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 */
22 class 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/[email protected]';
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 }
926