PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.1-beta.2
Jetpack – WP Security, Backup, Speed, & Growth v16.1-beta.2
16.1.2 16.2-a.1 16.1.1 16.1 16.1-beta 16.1-beta.2 16.1-beta.3 16.1-a.5 16.1-a.3 16.0.1 16.1-a.1 16.0 16.0-beta 16.0-a.7 16.0-a.5 15.9.1 16.0-a.3 16.0-a.1 15.9 15.9-beta 15.9-a.7 15.9-a.5 15.9-a.3 15.9-a.1 15.8 15.8-beta 15.8-a.7 15.8-a.5 5.2.5 5.3.4 5.4.4 5.5.5 5.6.5 5.7.5 5.8.4 5.9.4 6.0.4 6.1 6.1.1 6.1.2 6.1.3 6.1.4 6.1.5 6.2 6.2.1 6.2.2 6.2.3 6.2.4 6.2.5 6.3 6.3.1 6.3.2 6.3.3 6.3.4 6.3.5 6.3.6 6.3.7 6.4 6.4.1 6.4.2 6.4.3 6.4.4 6.4.5 6.4.6 6.5 6.5.1 6.5.2 6.5.3 6.5.4 6.6 6.6.1 6.6.2 6.6.3 6.6.4 6.6.5 6.7 6.7.1 6.7.2 6.7.3 6.7.4 6.8 6.8.1 6.8.2 6.8.3 6.8.4 6.8.5 6.9 6.9.1 6.9.2 6.9.3 6.9.4 7.0 7.0.1 7.0.2 7.0.3 7.0.4 7.0.5 7.1 7.1.1 7.1.2 7.1.3 7.1.4 7.1.5 7.2 7.2.1 7.2.1.1 7.2.2 7.2.3 7.2.4 7.2.5 7.3 7.3.0.1 7.3.1 7.3.1.1 7.3.2 7.3.3 7.3.4 7.3.5 7.4 7.4.1 7.4.2 7.4.3 7.4.4 7.4.5 7.5 7.5.0.1 7.5.1 7.5.2 7.5.3 7.5.4 7.5.5 7.5.6 7.5.7 7.6 7.6.1 7.6.2 7.6.3 7.6.4 7.7 7.7.1 7.7.2 7.7.3 7.7.4 7.7.5 7.7.6 7.8 7.8.1 7.8.2 7.8.3 7.8.4 7.9 7.9.1 7.9.2 7.9.3 7.9.4 8.0 8.0.1 8.0.2 8.0.3 8.1 8.1.1 8.1.2 8.1.3 8.1.4 8.2 8.2.0.1 8.2.1 8.2.2 8.2.3 8.2.4 8.2.5 8.2.6 8.3 8.3.1 8.3.2 8.3.3 8.4 8.4.1 8.4.2 8.4.3 8.4.4 8.4.5 8.5 8.5.1 8.5.2 8.5.3 8.6 8.6.1 8.6.2 8.6.3 8.6.4 8.7 8.7.0.1 8.7.1 8.7.2 8.7.3 8.7.4 8.8 8.8.1 8.8.2 8.8.3 8.8.4 8.8.5 8.9 8.9.1 8.9.2 8.9.3 8.9.4 9.0 9.0.1 9.0.2 9.0.3 9.0.4 9.0.5 9.1 9.1.1 9.1.2 9.1.3 9.2 9.2.1 9.2.2 9.2.3 9.2.4 9.3 9.3.1 9.3.2 9.3.3 9.3.4 9.3.5 9.4 9.4.1 9.4.2 9.4.3 9.4.4 9.5 9.5.1 9.5.2 9.5.3 9.5.4 9.5.5 9.6 9.6.1 9.6.2 9.6.3 9.6.4 9.7 9.7.1 9.7.2 15.7-beta.2 9.7.3 15.7.1 9.8 15.8-a.1 9.8.1 15.8-a.3 9.8.2 2.0.9 9.8.3 2.1.7 9.9 2.2.10 9.9.1 2.3.10 9.9.2 2.4.7 9.9.3 2.5.5 2.6.6 2.7.5 2.8.5 2.9.6 3.0.6 3.1.5 3.2.5 3.3.6 3.4.6 3.5.6 3.6.4 3.7.5 3.8.5 3.9.10 4.0.7 4.1.4 4.2.5 4.3.5 4.4.5 4.5.3 4.6.3 4.7.4 4.8.5 4.9.3 5.0.3 5.1.4 trunk 10.0 10.0.1 10.0.2 10.1 10.1.1 10.1.2 10.2 10.2.1 10.2.2 10.2.3 10.3 10.3.1 10.3.2 10.4 10.4.1 10.4.2 10.5 10.5.1 10.5.2 10.5.3 10.6 10.6.1 10.6.2 10.7 10.7.1 10.7.2 10.8 10.8.1 10.8.2 10.9 10.9.1 10.9.2 10.9.3 11.0 11.0.1 11.0.2 11.1 11.1.1 11.1.2 11.1.3 11.1.4 11.2 11.2.1 11.2.2 11.3 11.3.1 11.3.2 11.3.3 11.3.4 11.4 11.4.1 11.4.2 11.5 11.5.1 11.5.2 11.5.3 11.6 11.6.1 11.6.2 11.7 11.7.1 11.7.2 11.7.3 11.8 11.8.3 11.8.4 11.8.5 11.8.6 11.9 11.9.1 11.9.2 11.9.3 12.0 12.0.1 12.0.2 12.1 12.1.1 12.1.2 12.2 12.2.1 12.2.2 12.3 12.3.1 12.4 12.4.1 12.5 12.5.1 12.6 12.6.1 12.6.2 12.6.3 12.7 12.7.1 12.7.2 12.8 12.8.1 12.8.2 12.9 12.9.1 12.9.2 12.9.3 12.9.4 13.0 13.0.1 13.1 13.1.1 13.1.2 13.1.3 13.1.4 13.2 13.2.1 13.2.2 13.2.3 13.3 13.3.1 13.3.2 13.4 13.4.1 13.4.2 13.4.3 13.4.4 13.5 13.5.1 13.6 13.6.1 13.7 13.7.1 13.8 13.8.1 13.8.2 13.9 13.9.1 14.0 14.1 14.2 14.2.1 14.3 14.4 14.4.1 14.5 14.6 14.7 14.8 14.9 14.9.1 15.0 15.0.1 15.0.2 15.1 15.1.1 15.2 15.3 15.3.1 15.4 15.5 15.6 15.7 15.7-a.1 15.7-a.3 15.7-a.5 15.7-a.7 15.7-beta
jetpack / jetpack_vendor / automattic / jetpack-forms / src / contact-form / class-feedback-email-renderer.php
jetpack / jetpack_vendor / automattic / jetpack-forms / src / contact-form Last commit date
css 2 months ago images 2 months ago js 2 months ago libs 3 months ago templates 4 months ago class-contact-form-endpoint.php 1 month ago class-contact-form-field.php 1 month ago class-contact-form-plugin.php 1 month ago class-contact-form-shortcode.php 2 months ago class-contact-form.php 1 month ago class-editor-view.php 6 months ago class-feedback-author.php 2 months ago class-feedback-email-renderer.php 3 months ago class-feedback-field.php 2 months ago class-feedback-source.php 1 month ago class-feedback.php 3 weeks ago class-form-preview.php 4 months ago class-form-submission-error.php 9 months ago class-jetpack-form-endpoint.php 1 month ago class-util.php 1 month ago trait-country-code-utils.php 7 months ago
class-feedback-email-renderer.php
888 lines
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\Forms\Dashboard\Dashboard as Forms_Dashboard;
11 use Automattic\Jetpack\Forms\Jetpack_Forms;
12 use Jetpack_Tracks_Event;
13 use 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 */
21 class Feedback_Email_Renderer {
22
23 /**
24 * The color of the respondent email link in the email header.
25 *
26 * @var string
27 */
28 public const TEXT_SECONDARY_COLOR = '#757575';
29
30 /**
31 * The color of the links in the email.
32 *
33 * @var string
34 */
35 public const LINK_COLOR = '#1e1e1e';
36
37 /**
38 * The color of the text in the email.
39 *
40 * @var string
41 */
42 public const TEXT_COLOR = '#1e1e1e';
43
44 /**
45 * Font size for field labels.
46 *
47 * @var string
48 */
49 public const FONT_SIZE_FIELD_LABEL = '15px';
50
51 /**
52 * Font size for field values, chips, and respondent name.
53 *
54 * @var string
55 */
56 public const FONT_SIZE_FIELD_VALUE = '16px';
57
58 /**
59 * Font size for metadata section and powered-by text.
60 *
61 * @var string
62 */
63 public const FONT_SIZE_METADATA = '13px';
64
65 /**
66 * Font size for action buttons and respondent email.
67 *
68 * @var string
69 */
70 public const FONT_SIZE_BUTTON = '14px';
71
72 /**
73 * Font size for small annotations like file sizes.
74 *
75 * @var string
76 */
77 public const FONT_SIZE_SMALL = '12px';
78
79 /**
80 * Build the complete email content for a form submission.
81 *
82 * Assembles the email title, compiled form fields, footer, actions,
83 * respondent info, metadata, and wraps everything in the HTML template.
84 *
85 * @param int $post_id The feedback post ID.
86 * @param Contact_Form $form The form instance.
87 * @param Feedback $response The feedback response object.
88 * @param array $context_data Context data with keys:
89 * 'time' => string Formatted date/time string.
90 * 'url' => string Source page URL.
91 * 'comment_author' => string Author name.
92 * 'comment_author_email' => string Author email.
93 * 'comment_author_ip' => string Author IP address.
94 * 'is_spam' => bool Whether submission is spam.
95 * 'feedback_status' => string Post status of the feedback.
96 *
97 * @return array{title: string, message: string} The email title and rendered HTML message.
98 */
99 public static function build_email_content( $post_id, $form, $response, $context_data ) {
100 $time = $context_data['time'];
101 $url = $context_data['url'];
102 $comment_author = $context_data['comment_author'];
103 $comment_author_email = $context_data['comment_author_email'];
104 $comment_author_ip = $context_data['comment_author_ip'];
105 $is_spam = $context_data['is_spam'];
106 $is_test = ! empty( $context_data['is_test'] );
107 $feedback_status = $context_data['feedback_status'];
108
109 /**
110 * Filter the title used in the response email.
111 *
112 * @module contact-form
113 *
114 * @since 0.18.0
115 *
116 * @param string the title of the email
117 */
118 $title = (string) apply_filters( 'jetpack_forms_response_email_title', '' );
119 $title = ! empty( $title ) ? sprintf( '<h1 class="email-header">%s</h1>', esc_html( $title ) ) : '';
120 $message = self::get_compiled_form_for_email( $post_id, $form );
121
122 if ( is_user_logged_in() ) {
123 $sent_by_text = sprintf(
124 // translators: the name of the site.
125 '<br />' . esc_html__( 'Sent by a verified %s user.', 'jetpack-forms' ) . '<br />',
126 isset( $GLOBALS['current_site']->site_name ) && $GLOBALS['current_site']->site_name ? $GLOBALS['current_site']->site_name : '"' . get_option( 'blogname' ) . '"'
127 );
128 } else {
129 $sent_by_text = '<br />' . esc_html__( 'Sent by an unverified visitor to your site.', 'jetpack-forms' ) . '<br />';
130 }
131
132 $footer_time = sprintf(
133 /* translators: Placeholder is the date and time when a form was submitted. */
134 esc_html__( 'Time: %1$s', 'jetpack-forms' ),
135 $time
136 );
137 $footer_ip = null;
138 if ( $comment_author_ip ) {
139 $ip_lookup_url = sprintf( 'https://jetpack.com/redirect/?source=ip-lookup&path=%s', rawurlencode( $comment_author_ip ) );
140 $comment_author_ip_with_link = '<a href="' . esc_url( $ip_lookup_url ) . '">' . esc_html( $comment_author_ip ) . '</a>';
141 $comment_author_ip_with_flag = ( $response->get_country_flag() ? $response->get_country_flag() . ' ' : '' ) . $comment_author_ip_with_link;
142 $footer_ip = sprintf(
143 /* translators: Placeholder is the IP address of the person who submitted a form. */
144 esc_html__( 'IP Address: %1$s', 'jetpack-forms' ),
145 $comment_author_ip_with_flag
146 );
147 }
148 $footer_browser = null;
149 if ( $response->get_browser() ) {
150 $footer_browser = sprintf(
151 /* translators: Placeholder is the browser and platform used to submit a form. */
152 esc_html__( 'Browser: %1$s', 'jetpack-forms' ),
153 $response->get_browser()
154 ) . '<br />';
155 }
156
157 $footer_url = sprintf(
158 /* translators: Placeholder is the URL of the page where a form was submitted. */
159 __( 'Source URL: %1$s', 'jetpack-forms' ),
160 esc_url( $url )
161 );
162
163 // Get the status of the feedback.
164 $status = $is_spam ? 'spam' : 'inbox';
165
166 // Build the dashboard URL with the status and the feedback's post id if we have a post id.
167 $dashboard_url = '';
168 $mark_as_spam_url = '';
169 $footer_mark_as_spam_url = '';
170
171 /**
172 * Filters whether to show action buttons in notification emails.
173 *
174 * @module contact-form
175 *
176 * @since 7.20.0
177 *
178 * @param bool $show Whether to show the action buttons. Default true.
179 */
180 $show_email_actions = apply_filters( 'jetpack_forms_email_show_actions', true );
181
182 if ( $feedback_status !== 'jp-temp-feedback' && $show_email_actions ) {
183 $dashboard_url = Forms_Dashboard::get_forms_admin_url( $status, $post_id );
184 // Test responses don't get a Mark-as-spam link in the email — marking
185 // a test entry as spam from email is confusing and the form owner can
186 // always do it from the dashboard if they want.
187 if ( ! $is_test ) {
188 $mark_as_spam_url = self::add_mark_as_spam_to_url( $dashboard_url );
189 $footer_mark_as_spam_url = sprintf(
190 '<a href="%1$s">%2$s</a>',
191 esc_url( $mark_as_spam_url ),
192 __( 'Mark as spam', 'jetpack-forms' )
193 );
194 }
195 }
196
197 $footer = implode(
198 '',
199 /**
200 * Filter the footer used in the response email.
201 *
202 * @module contact-form
203 *
204 * @since 0.18.0
205 *
206 * @param array the lines of the footer, one line per array element.
207 */
208 apply_filters(
209 'jetpack_forms_response_email_footer',
210 array_filter(
211 array(
212 '<span style="font-size: ' . self::FONT_SIZE_SMALL . '">',
213 $footer_time . '<br />',
214 $footer_ip ? $footer_ip . '<br />' : null,
215 $footer_browser ? $footer_browser . '<br />' : null,
216 $footer_url . '<br /><br />',
217 $footer_mark_as_spam_url ? $footer_mark_as_spam_url . '<br />' : null,
218 $sent_by_text,
219 '</span>',
220 )
221 )
222 )
223 );
224
225 // Build the actions with both Mark as spam and View in dashboard buttons.
226 // Use fully table-based layout for maximum email client compatibility - no display:inline-block.
227 $actions = '';
228 if ( $dashboard_url ) {
229 if ( $is_test ) {
230 // For test submissions, only show the "View in dashboard" button
231 // centered — we deliberately drop the Mark-as-spam shortcut.
232 $actions = sprintf(
233 '<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="button-table" align="center" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin: 0 auto;">
234 <tr>
235 <td class="button-cell" style="text-align: center; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
236 <a href="%1$s" class="action-button action-button-primary" style="display: inline-block; background-color: #3858e9; color: #ffffff; border-radius: 4px; font-size: ' . self::FONT_SIZE_BUTTON . '; font-weight: 500; text-decoration: none; padding: 12px 24px; text-align: center; mso-padding-alt: 0;">%2$s</a>
237 </td>
238 </tr>
239 </table>',
240 esc_url( $dashboard_url ),
241 __( 'View in dashboard', 'jetpack-forms' )
242 );
243 } else {
244 $actions = sprintf(
245 '<table role="presentation" border="0" cellpadding="0" cellspacing="0" class="button-table" align="center" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin: 0 auto;">
246 <tr>
247 <td class="button-cell" width="50%%" style="text-align: right; padding-right: 8px; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
248 <a href="%1$s" class="action-button action-button-secondary" style="display: inline-block; background-color: transparent; color: %5$s; border: 1px solid #1e1e1e; border-radius: 4px; font-size: ' . self::FONT_SIZE_BUTTON . '; font-weight: 500; text-decoration: none; padding: 12px 24px; text-align: center; mso-padding-alt: 0;">%2$s</a>
249 </td>
250 <td class="button-cell" width="50%%" style="text-align: left; padding-left: 8px; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
251 <a href="%3$s" class="action-button action-button-primary" style="display: inline-block; background-color: #3858e9; color: #ffffff; border-radius: 4px; font-size: ' . self::FONT_SIZE_BUTTON . '; font-weight: 500; text-decoration: none; padding: 12px 24px; text-align: center; mso-padding-alt: 0;">%4$s</a>
252 </td>
253 </tr>
254 </table>',
255 esc_url( $mark_as_spam_url ),
256 __( 'Mark as spam', 'jetpack-forms' ),
257 esc_url( $dashboard_url ),
258 __( 'View in dashboard', 'jetpack-forms' ),
259 self::LINK_COLOR
260 );
261 }
262 }
263
264 // Build respondent info for the new email template.
265 $respondent_info = array(
266 'name' => $comment_author,
267 'email' => $comment_author_email,
268 'avatar' => $response->get_author_avatar(),
269 );
270
271 // Get the form title for source metadata.
272 $form_title = $form->get_attribute( 'formTitle' );
273 if ( empty( $form_title ) && $form->current_post ) {
274 $form_title = Contact_Form::get_post_property( $form->current_post, 'post_title' );
275 }
276
277 // Test responses don't have a real source page; surface them as
278 // "Form preview" in the metadata table to match the dashboard.
279 if ( $is_test ) {
280 $source_label = __( 'Form preview', 'jetpack-forms' );
281 $source_url = '';
282 } else {
283 $source_label = $form_title;
284 $source_url = $url;
285 }
286
287 // Build metadata for the new email template.
288 $metadata = array(
289 'date' => $time,
290 'source' => $source_label,
291 'source_url' => $source_url,
292 'device' => $response->get_browser(),
293 'ip' => $comment_author_ip,
294 'ip_flag' => $response->get_country_flag(),
295 'logged_in_user' => $response->get_logged_in_user(),
296 );
297
298 /**
299 * Filters the message sent via email after a successful form submission.
300 *
301 * @module contact-form
302 *
303 * @since 1.3.1
304 *
305 * @param string $message Feedback email message.
306 * @param string $message Feedback email message as an array
307 */
308 $message = apply_filters( 'contact_form_message', implode( '', $message ), $message );
309
310 // Render a prominent TEST SUBMISSION banner when this came from a form
311 // preview, so the form owner can immediately tell that this response is
312 // a synthetic test. It is injected at the very top of the email so the
313 // rest of the body still looks like a normal submission email.
314 $banner = $is_test ? self::build_test_submission_banner() : '';
315
316 // This is called after `contact_form_message`, in order to preserve back-compat.
317 $message = self::wrap_message_in_html_tags( $title, $message, $footer, $actions, $respondent_info, $metadata, $banner );
318
319 return array(
320 'title' => $title,
321 'message' => $message,
322 );
323 }
324
325 /**
326 * Build the HTML banner inserted at the top of a test-submission email body.
327 *
328 * Uses inline styles and a nested table layout for email-client compatibility.
329 * Mirrors the @wordpress/ui Notice component (warning intent): warm amber fill,
330 * 1px amber border with 8px radius, decorative info icon, 13px/20px body copy.
331 *
332 * @return string
333 */
334 private static function build_test_submission_banner() {
335 return sprintf(
336 '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%%" class="test-submission-banner" style="border-collapse: collapse; margin: 0 0 24px 0;">
337 <tr>
338 <td class="test-submission-banner-cell" style="padding: 12px; background-color: #fff7e0; border: 1px solid #d0b381; border-radius: 8px; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif; color: #2e1900; font-size: 13px; line-height: 20px;">%s</td>
339 </tr>
340 </table>',
341 esc_html__( 'Test response via form preview.', 'jetpack-forms' )
342 );
343 }
344
345 /**
346 * Adds the mark_as_spam parameter to a dashboard URL.
347 *
348 * This method handles both legacy and wp-build dashboard URLs:
349 * - Legacy: appends &mark_as_spam to the hash fragment
350 * - WP-Build: adds mark_as_spam to the path inside the p parameter
351 *
352 * @param string $url The dashboard URL.
353 * @return string The URL with mark_as_spam parameter added.
354 */
355 private static function add_mark_as_spam_to_url( $url ) {
356 // Check if this is a wp-build URL (contains &p= parameter).
357 if ( strpos( $url, '&p=' ) !== false ) {
358 // WP-Build URL format: admin.php?page=jetpack-forms-responses-wp-admin&p=/responses/inbox?responseIds=["123"]
359 // We need to add &mark_as_spam=1 inside the p parameter path.
360 $parts = explode( '&p=', $url, 2 );
361
362 if ( count( $parts ) === 2 ) {
363 $base_url = $parts[0];
364 $path = rawurldecode( $parts[1] );
365
366 // Add mark_as_spam parameter to the path.
367 $separator = strpos( $path, '?' ) !== false ? '&' : '?';
368 $path .= $separator . 'mark_as_spam=1';
369
370 return $base_url . '&p=' . rawurlencode( $path );
371 }
372 }
373
374 // Legacy URL format: admin.php?page=jetpack-forms-admin#/responses?status=inbox&r=123
375 // Append &mark_as_spam to the hash fragment.
376 return $url . '&mark_as_spam';
377 }
378
379 /**
380 * Returns a compiled form with labels and values formatted for the email response
381 * in a form of an array of lines.
382 *
383 * @param int $feedback_id - the feedback ID.
384 * @param Contact_Form $form - the form.
385 *
386 * @return array $lines
387 */
388 public static function get_compiled_form_for_email( $feedback_id, $form ) {
389 $compiled_form = array();
390 $field_collection = array();
391 $response = Feedback::get( $feedback_id );
392
393 if ( $response instanceof Feedback ) {
394 // Get both formats: 'all' for backward-compat filter, 'collection' for type-aware rendering.
395 $compiled_form = $response->get_compiled_fields( 'email', 'all' );
396 $field_collection = $response->get_compiled_fields( 'email_html', 'collection' );
397 }
398
399 /**
400 * This filter allows a site owner to customize the response to be emailed, by adding their own HTML around it for example.
401 *
402 * @module contact-form
403 *
404 * @since 0.18.0
405 *
406 * @param array $compiled_form the form response to be filtered
407 * @param int $feedback_id the ID of the feedback form
408 * @param Contact_Form $form a copy of this object
409 */
410 $updated_compiled_form = apply_filters( 'jetpack_forms_response_email', $compiled_form, $feedback_id, $form );
411 if ( $updated_compiled_form !== $compiled_form ) {
412 // Filter was customized — use old rendering path for backward compat.
413 $compiled_form = $updated_compiled_form;
414 foreach ( $compiled_form as $key => $value ) {
415 if ( ! is_array( $value ) || ! isset( $value['label'] ) ) {
416 continue;
417 }
418 $safe_display_label = Contact_Form::escape_and_sanitize_field_label( $value['label'] );
419 $safe_display_value = Contact_Form::escape_and_sanitize_field_value( $value['value'] );
420
421 if ( ! empty( $safe_display_label ) ) {
422 $compiled_form[ $key ] = sprintf(
423 '<p><strong>%1$s</strong><br /><span>%2$s</span></p>',
424 Util::maybe_add_colon_to_label( $safe_display_label ),
425 $safe_display_value
426 );
427 } else {
428 $compiled_form[ $key ] = sprintf(
429 '<p><span>%s</span></p>',
430 $safe_display_value
431 );
432 }
433 }
434 } else {
435 // No filter customization — use new type-aware rendering.
436 $compiled_form = array();
437 foreach ( $field_collection as $field_data ) {
438 $compiled_form[] = self::format_field_for_email( $field_data );
439 }
440 }
441
442 return $compiled_form;
443 }
444
445 /**
446 * Get the icon name for a given field type.
447 *
448 * @param string $type The field type.
449 * @return string The icon name.
450 */
451 private static function get_field_icon_name( $type ) {
452 $map = array(
453 'text' => 'field-text',
454 'name' => 'field-text',
455 'email' => 'field-email',
456 'textarea' => 'field-textarea',
457 'select' => 'field-select',
458 'radio' => 'field-single-choice',
459 'checkbox' => 'field-checkbox',
460 'checkbox-multiple' => 'field-multiple-choice',
461 'phone' => 'field-telephone',
462 'telephone' => 'field-telephone',
463 'number' => 'field-number',
464 'slider' => 'field-slider',
465 'date' => 'field-date',
466 'time' => 'field-time',
467 'url' => 'field-url',
468 'rating' => 'field-rating',
469 'image-select' => 'field-image-select',
470 'file' => 'field-file',
471 'consent' => 'field-consent',
472 'hidden' => 'field-hidden',
473 );
474 return $map[ $type ] ?? 'field-text';
475 }
476
477 /**
478 * Format a single field for the email notification using type-aware rendering.
479 *
480 * Takes a collection item from get_compiled_fields( 'email', 'collection' )
481 * and produces a table row with an icon, label, and type-specific value.
482 *
483 * @param array $field_data Field data with keys: label, value, type, id, key, meta.
484 * @return string HTML for the field row.
485 */
486 private static function format_field_for_email( $field_data ) {
487 $label = $field_data['label'] ?? '';
488 $value = $field_data['value'] ?? '';
489 $type = $field_data['type'] ?? 'text';
490
491 $safe_label = Contact_Form::escape_and_sanitize_field_label( $label );
492 $icon_name = self::get_field_icon_name( $type );
493 $icon_url = Jetpack_Forms::plugin_url() . 'contact-form/images/field-icons/' . $icon_name . '@2x.png';
494
495 // Value is already rendered as HTML by Feedback_Field::get_render_email_html_value().
496 $rendered_value = $value;
497
498 // Build the field row as a table with icon + content.
499 $html = '<table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" style="border-bottom: 1px solid #F0F0F0; padding: 0; margin: 0;">';
500 $html .= '<tr>';
501 $html .= '<td class="field-icon-cell" width="24" valign="top" style="padding: 18px 16px 20px 0; width: 24px; vertical-align: top; -webkit-user-select: none; user-select: none;">';
502 $html .= sprintf(
503 '<img src="%s" width="24" height="24" alt="" style="display: block; width: 24px; height: 24px; -webkit-user-select: none; user-select: none;" />',
504 esc_url( $icon_url )
505 );
506 $html .= '</td>';
507 $html .= '<td valign="top" style="padding: 20px 0;">';
508 if ( ! empty( $safe_label ) ) {
509 $html .= sprintf(
510 '<div style="font-size: ' . self::FONT_SIZE_FIELD_LABEL . '; color: %s; line-height: 1.4; margin-bottom: 8px;">%s</div>',
511 self::TEXT_SECONDARY_COLOR,
512 esc_html( $safe_label )
513 );
514 }
515 $html .= sprintf(
516 '<div style="font-size: ' . self::FONT_SIZE_FIELD_VALUE . '; color: %s; line-height: 1.5;">%s</div>',
517 self::TEXT_COLOR,
518 $rendered_value
519 );
520 $html .= '</td>';
521 $html .= '</tr>';
522 $html .= '</table>';
523
524 return $html;
525 }
526
527 /**
528 * Wrapper for wp_mail() that enables HTML messages with text alternatives
529 *
530 * @param string|array $to Array or comma-separated list of email addresses to send message.
531 * @param string $subject Email subject.
532 * @param string $message Message contents.
533 * @param string|array $headers Optional. Additional headers.
534 * @param string|array $attachments Optional. Files to attach.
535 *
536 * @return bool Whether the email contents were sent successfully.
537 */
538 public static function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
539 add_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
540 add_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
541
542 $result = \wp_mail( $to, $subject, $message, $headers, $attachments );
543
544 remove_filter( 'wp_mail_content_type', __CLASS__ . '::get_mail_content_type' );
545 remove_action( 'phpmailer_init', __CLASS__ . '::add_plain_text_alternative' );
546
547 return $result;
548 }
549
550 /**
551 * Get the content type that should be assigned to outbound emails
552 *
553 * @return string
554 */
555 public static function get_mail_content_type() {
556 return 'text/html';
557 }
558
559 /**
560 * Add a plain-text alternative part to an outbound email
561 *
562 * This makes the message more accessible to mail clients that aren't HTML-aware, and decreases the likelihood
563 * that the message will be flagged as spam.
564 *
565 * @param PHPMailer $phpmailer - the phpmailer.
566 */
567 public static function add_plain_text_alternative( $phpmailer ) {
568 // phpcs:disable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
569
570 // Remove the preheader (hidden email preview text) so it doesn't duplicate the title in plain text.
571 $alt_body = preg_replace( '/<span class="preheader">.*?<\/span>/s', '', $phpmailer->Body );
572
573 // Add an extra break so that the extra space above the <p> is preserved after the <p> is stripped out.
574 $alt_body = str_replace( '<p>', '<p><br />', $alt_body );
575
576 // Convert <br> to \n breaks, to preserve the space between lines that we want to keep.
577 $alt_body = str_replace( array( '<br>', '<br />' ), "\n", $alt_body );
578
579 // Convert <div> to \n breaks, to preserve space between lines for new email formatting.
580 $alt_body = str_replace( '<div', "\n<div", $alt_body );
581
582 // Convert <hr> to an plain-text equivalent, to preserve the integrity of the message.
583 $alt_body = str_replace( array( '<hr>', '<hr />' ), "----\n", $alt_body );
584
585 // Trim the plain text message to remove the \n breaks that were after <doctype>, <html>, and <body>.
586 $phpmailer->AltBody = trim( wp_strip_all_tags( $alt_body ) );
587 // phpcs:enable WordPress.NamingConventions.ValidVariableName.UsedPropertyNotSnakeCase
588 }
589
590 /**
591 * Wrap a message body with the appropriate in HTML tags
592 *
593 * This helps to ensure correct parsing by clients, and also helps avoid triggering spam filtering rules
594 *
595 * @param string $title - title of the email.
596 * @param string $body - the message body.
597 * @param string $footer - the footer containing meta information.
598 * @param string $actions - HTML for actions displayed in the email.
599 * @param array $respondent_info - Optional. Respondent information array with 'name', 'email', 'avatar'.
600 * @param array $metadata - Optional. Metadata array with 'date', 'source', 'source_url', 'device', 'ip', 'ip_flag', 'logged_in_user' (with display_name, username, id).
601 * @param string $banner - Optional. HTML banner inserted at the very top of the email body (above the title).
602 *
603 * @return string
604 */
605 public static function wrap_message_in_html_tags( $title, $body, $footer, $actions = '', $respondent_info = array(), $metadata = array(), $banner = '' ) {
606 // Don't do anything if the message was already wrapped in HTML tags
607 // That could have be done by a plugin via filters.
608 if ( str_contains( $body, '<html' ) ) {
609 return $body;
610 }
611
612 $template = '';
613 $style = '';
614
615 // The hash is just used to anonymize the admin email and have a unique identifier for the event.
616 // The secret key used could have been a random string, but it's better to use the version number to make it easier to track.
617 $event = new Jetpack_Tracks_Event(
618 (object) array(
619 '_en' => 'jetpack_forms_email_open',
620 '_ui' => hash_hmac( 'md5', get_option( 'admin_email' ), JETPACK__VERSION ),
621 '_ut' => 'anon',
622 )
623 );
624
625 $tracking_pixel = '<img src="' . $event->build_pixel_url() . '" alt="" width="1" height="1" />';
626
627 /**
628 * Filter the filename of the template HTML surrounding the response email. The PHP file will return the template in a variable called $template.
629 *
630 * @module contact-form
631 *
632 * @since 0.18.0
633 *
634 * @param string the filename of the HTML template used for response emails to the form owner.
635 */
636 $print_style = null; // May be set by the template file loaded below.
637 require apply_filters( 'jetpack_forms_response_email_template', __DIR__ . '/templates/email-response.php' );
638
639 /**
640 * Filter the HTML for the powered by section in the email.
641 *
642 * @module contact-form
643 *
644 * @since 7.2.0
645 *
646 * @param string $powered_by_html The HTML for the powered by section in the email.
647 */
648 // Use table-based layout for maximum email client compatibility.
649 $logo_url = Jetpack_Forms::plugin_url() . 'contact-form/images/field-icons/jetpack-logo@2x.png';
650 $powered_by_html = apply_filters(
651 'jetpack_forms_email_powered_by_html',
652 str_replace(
653 "\t",
654 '',
655 '
656 <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" class="powered-by-table" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin-top: 24px;">
657 <tr>
658 <td align="center" class="powered-by" style="padding: 24px 0 0 0; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
659 <img src="' . esc_url( $logo_url ) . '" alt="Jetpack" width="20" height="20" style="vertical-align: middle; margin-right: 6px; border: 0; outline: none; text-decoration: none; -webkit-user-select: none; user-select: none;">
660 <span style="font-size: ' . self::FONT_SIZE_METADATA . '; color: #50575e; line-height: 20px;">' .
661 sprintf(
662 // translators: %1$s is a link to the Jetpack Forms page.
663 __( 'Powered by %1$s', 'jetpack-forms' ),
664 '<a href="https://jetpack.com/forms/?utm_source=jetpack-forms&utm_medium=email&utm_campaign=form-submissions" style="font-size: ' . self::FONT_SIZE_METADATA . '; color: #50575e; text-decoration: none;">Jetpack Forms</a>'
665 ) . '</span>
666 </td>
667 </tr>
668 </table>'
669 )
670 );
671
672 // Generate respondent info HTML.
673 $respondent_html = self::generate_respondent_info_html( $respondent_info );
674
675 // Generate metadata HTML.
676 $metadata_html = self::generate_metadata_html( $metadata );
677
678 // Minify CSS to stay under Gmail's 8,192-char limit for style blocks.
679 // The template file keeps readable formatting; we strip it here at render time.
680 $style = self::minify_css( $style );
681
682 $html_message = sprintf(
683 // The tabs are just here so that the raw code is correctly formatted for developers
684 // They're removed so that they don't affect the final message sent to users.
685 str_replace(
686 "\t",
687 '',
688 $template
689 ),
690 $title,
691 $body,
692 '',
693 '',
694 $footer,
695 $style,
696 $tracking_pixel,
697 $actions,
698 $powered_by_html,
699 $respondent_html,
700 $metadata_html,
701 $banner
702 );
703
704 // Inject print styles into <body> for Outlook.com compatibility (it strips <head> styles
705 // but preserves <body> styles). The same styles are already in <head> for Gmail and others.
706 // This is done after sprintf to avoid % signs in CSS being interpreted as format specifiers.
707 // @phan-suppress-next-line PhanRedundantCondition -- $print_style is set by the template file loaded via require above.
708 if ( ! empty( $print_style ) ) {
709 $html_message = str_replace( '</body>', '<style type="text/css">' . $print_style . '</style></body>', $html_message );
710 }
711
712 return $html_message;
713 }
714
715 /**
716 * Generate HTML for respondent info section in email.
717 *
718 * @param array $respondent_info Array with 'name', 'email', 'avatar' keys.
719 * @return string HTML for respondent info section.
720 */
721 private static function generate_respondent_info_html( $respondent_info ) {
722 if ( empty( $respondent_info ) ) {
723 return '';
724 }
725
726 $name = isset( $respondent_info['name'] ) ? esc_html( $respondent_info['name'] ) : '';
727 $email = isset( $respondent_info['email'] ) ? esc_html( $respondent_info['email'] ) : '';
728 $avatar = isset( $respondent_info['avatar'] ) ? esc_url( $respondent_info['avatar'] ) : '';
729
730 // Don't show section if there's no name or email.
731 if ( empty( $name ) && empty( $email ) ) {
732 return '';
733 }
734
735 // Get initials for avatar fallback.
736 $initials = '';
737 if ( ! empty( $name ) ) {
738 $name_parts = explode( ' ', $name );
739 $initials = strtoupper( substr( $name_parts[0], 0, 1 ) );
740 if ( count( $name_parts ) > 1 ) {
741 $initials .= strtoupper( substr( end( $name_parts ), 0, 1 ) );
742 }
743 } elseif ( ! empty( $email ) ) {
744 $initials = strtoupper( substr( $email, 0, 1 ) );
745 }
746
747 // Avatar content - either image or initials.
748 $avatar_content = ! empty( $avatar )
749 ? '<img src="' . $avatar . '" alt="" width="48" height="48" style="border-radius: 24px; vertical-align: middle;">'
750 : esc_html( $initials );
751
752 // Use table layout for maximum email client compatibility.
753 $html = '
754 <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="respondent-table" width="100%" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin-bottom: 16px;">
755 <tr>
756 <td class="respondent-avatar-cell" style="width: 64px; vertical-align: middle; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
757 <!--[if mso]>
758 <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="48" height="48" style="width: 48px; height: 48px;">
759 <tr>
760 <td align="center" valign="middle" style="width: 48px; height: 48px; background-color: #f0f0f0; border-radius: 24px; font-size: 18px; font-weight: 600; color: #50575e;">
761 <![endif]-->
762 <div class="respondent-avatar-wrapper" style="width: 48px; height: 48px; border-radius: 24px; background-color: #f0f0f0; text-align: center; line-height: 48px; font-size: 18px; font-weight: 600; color: #50575e;">
763 ' . $avatar_content . '
764 </div>
765 <!--[if mso]>
766 </td>
767 </tr>
768 </table>
769 <![endif]-->
770 </td>
771 <td class="respondent-details-cell" style="vertical-align: middle; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
772 ' . ( ! empty( $name ) ? '<div class="respondent-name" style="font-size: ' . self::FONT_SIZE_FIELD_VALUE . '; font-weight: 600; color: ' . self::TEXT_COLOR . '; margin: 0 0 2px 0; line-height: 1.4;">' . $name . '</div>' : '' ) . '
773 ' . ( ! empty( $email ) ? '<div class="respondent-email" style="font-size: ' . self::FONT_SIZE_BUTTON . '; margin: 0; line-height: 1.4;"><a href="mailto:' . $email . '" style="color: ' . self::TEXT_SECONDARY_COLOR . '; text-decoration: underline;">' . $email . '</a></div>' : '' ) . '
774 </td>
775 </tr>
776 </table>';
777
778 return str_replace( "\t", '', $html );
779 }
780
781 /**
782 * Generate HTML for metadata section in email.
783 *
784 * @param array $metadata Array with 'date', 'source', 'source_url', 'device', 'ip', 'ip_flag', 'logged_in_user' (with display_name, username, id) keys.
785 * @return string HTML for metadata section.
786 */
787 private static function generate_metadata_html( $metadata ) {
788 if ( empty( $metadata ) ) {
789 return '';
790 }
791
792 $rows = array();
793
794 // Date row.
795 if ( ! empty( $metadata['date'] ) ) {
796 $rows[] = self::generate_metadata_row( __( 'Date', 'jetpack-forms' ), esc_html( $metadata['date'] ) );
797 }
798
799 // Source row.
800 if ( ! empty( $metadata['source'] ) ) {
801 $source_value = esc_html( $metadata['source'] );
802 if ( ! empty( $metadata['source_url'] ) ) {
803 $source_value = '<a href="' . esc_url( $metadata['source_url'] ) . '" style="color: ' . self::LINK_COLOR . '; text-decoration: underline;">' . $source_value . '</a>';
804 }
805 $rows[] = self::generate_metadata_row( __( 'Source', 'jetpack-forms' ), $source_value );
806 }
807
808 // Device row.
809 if ( ! empty( $metadata['device'] ) ) {
810 $rows[] = self::generate_metadata_row( __( 'Device', 'jetpack-forms' ), esc_html( $metadata['device'] ) );
811 }
812
813 // IP Address row.
814 if ( ! empty( $metadata['ip'] ) ) {
815 $ip_value = '';
816 if ( ! empty( $metadata['ip_flag'] ) ) {
817 $ip_value .= $metadata['ip_flag'] . ' ';
818 }
819 $ip_value .= esc_html( $metadata['ip'] );
820 $rows[] = self::generate_metadata_row( __( 'IP address', 'jetpack-forms' ), $ip_value );
821 }
822
823 // Logged in user row.
824 if ( ! empty( $metadata['logged_in_user'] ) && isset( $metadata['logged_in_user']['id'] ) ) {
825 $user_id = $metadata['logged_in_user']['id'];
826 $user_value = '#' . $user_id;
827 if ( ! empty( $metadata['logged_in_user']['display_name'] ) ) {
828 $user_value = $metadata['logged_in_user']['display_name'] . ' (#' . $user_id . ')';
829 } elseif ( ! empty( $metadata['logged_in_user']['username'] ) ) {
830 $user_value = $metadata['logged_in_user']['username'] . ' (#' . $user_id . ')';
831 }
832 $rows[] = self::generate_metadata_row( __( 'Logged-in user', 'jetpack-forms' ), esc_html( $user_value ) );
833 }
834
835 if ( empty( $rows ) ) {
836 return '';
837 }
838
839 // Use table layout for maximum email client compatibility.
840 $html = '
841 <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="metadata-table" width="100%" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; margin-bottom: 24px;">
842 ' . implode( '', $rows ) . '
843 <tr><td colspan="2" style="padding: 24px 0 0 0; border-bottom: 1px solid #E4E4E7; font-size: 0; line-height: 0;">&nbsp;</td></tr>
844 </table>';
845
846 return str_replace( "\t", '', $html );
847 }
848
849 /**
850 * Generate a single metadata row.
851 *
852 * @param string $label The label text.
853 * @param string $value The value (can contain HTML).
854 * @return string HTML for the row.
855 */
856 private static function generate_metadata_row( $label, $value ) {
857 return '
858 <tr>
859 <td class="metadata-label" style="color: #50575e; width: 100px; padding: 4px 12px 4px 0; font-size: ' . self::FONT_SIZE_METADATA . '; vertical-align: top; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif; line-height: 1.4;">' . esc_html( $label ) . ':</td>
860 <td class="metadata-value" style="color: #1e1e1e; padding: 4px 0; font-size: ' . self::FONT_SIZE_METADATA . '; vertical-align: top; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif; line-height: 1.4;">' . $value . '</td>
861 </tr>';
862 }
863
864 /**
865 * Minify a CSS string by removing comments, collapsing whitespace,
866 * and stripping unnecessary characters.
867 *
868 * Gmail imposes an 8,192-character limit across all <style> blocks.
869 * This keeps the template file readable while fitting under the limit.
870 *
871 * @param string $css The CSS string (may include <style> tags).
872 * @return string The minified CSS string.
873 */
874 private static function minify_css( $css ) {
875 // Remove CSS comments.
876 $css = preg_replace( '/\/\*.*?\*\//s', '', $css );
877 // Collapse all whitespace (tabs, newlines, spaces) into single spaces.
878 $css = preg_replace( '/\s+/', ' ', $css );
879 // Remove spaces around CSS punctuation: { } ; : ,
880 $css = preg_replace( '/\s*([{};,])\s*/', '$1', $css );
881 // Remove space after colons in all contexts.
882 $css = preg_replace( '/:\s+/', ':', $css );
883 // Remove trailing semicolons before closing braces.
884 $css = str_replace( ';}', '}', $css );
885 return trim( $css );
886 }
887 }
888