PluginProbe ʕ •ᴥ•ʔ
Jetpack – WP Security, Backup, Speed, & Growth / 16.2-a.1
Jetpack – WP Security, Backup, Speed, & Growth v16.2-a.1
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 3 days ago js 2 months ago libs 3 months ago templates 4 months ago class-conditional-logic.php 3 days ago class-contact-form-endpoint.php 1 week ago class-contact-form-field.php 3 days ago class-contact-form-plugin.php 3 days ago class-contact-form-shortcode.php 2 months ago class-contact-form.php 3 days ago class-editor-view.php 6 months ago class-feedback-author.php 3 days ago class-feedback-email-renderer.php 3 days ago class-feedback-field.php 3 days ago class-feedback-source.php 1 month ago class-feedback.php 3 days ago class-form-preview.php 3 days 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
926 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\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/jetpack-logo@2x.png';
688 $powered_by_html = apply_filters(
689 'jetpack_forms_email_powered_by_html',
690 str_replace(
691 "\t",
692 '',
693 '
694 <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="100%" class="powered-by-table" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin-top: 24px;">
695 <tr>
696 <td align="center" class="powered-by" style="padding: 24px 0 0 0; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
697 <img src="' . esc_url( $logo_url ) . '" alt="Jetpack" width="20" height="20" style="vertical-align: middle; margin-right: 6px; border: 0; outline: none; text-decoration: none; -webkit-user-select: none; user-select: none;">
698 <span style="font-size: ' . self::FONT_SIZE_METADATA . '; color: #50575e; line-height: 20px;">' .
699 sprintf(
700 // translators: %1$s is a link to the Jetpack Forms page.
701 __( 'Powered by %1$s', 'jetpack-forms' ),
702 '<a href="https://jetpack.com/forms/?utm_source=jetpack-forms&utm_medium=email&utm_campaign=form-submissions" style="font-size: ' . self::FONT_SIZE_METADATA . '; color: #50575e; text-decoration: none;">Jetpack Forms</a>'
703 ) . '</span>
704 </td>
705 </tr>
706 </table>'
707 )
708 );
709
710 // Generate respondent info HTML.
711 $respondent_html = self::generate_respondent_info_html( $respondent_info );
712
713 // Generate metadata HTML.
714 $metadata_html = self::generate_metadata_html( $metadata );
715
716 // Minify CSS to stay under Gmail's 8,192-char limit for style blocks.
717 // The template file keeps readable formatting; we strip it here at render time.
718 $style = self::minify_css( $style );
719
720 $html_message = sprintf(
721 // The tabs are just here so that the raw code is correctly formatted for developers
722 // They're removed so that they don't affect the final message sent to users.
723 str_replace(
724 "\t",
725 '',
726 $template
727 ),
728 $title,
729 $body,
730 '',
731 '',
732 $footer,
733 $style,
734 $tracking_pixel,
735 $actions,
736 $powered_by_html,
737 $respondent_html,
738 $metadata_html,
739 $banner
740 );
741
742 // Inject print styles into <body> for Outlook.com compatibility (it strips <head> styles
743 // but preserves <body> styles). The same styles are already in <head> for Gmail and others.
744 // This is done after sprintf to avoid % signs in CSS being interpreted as format specifiers.
745 // @phan-suppress-next-line PhanRedundantCondition -- $print_style is set by the template file loaded via require above.
746 if ( ! empty( $print_style ) ) {
747 $html_message = str_replace( '</body>', '<style type="text/css">' . $print_style . '</style></body>', $html_message );
748 }
749
750 return $html_message;
751 }
752
753 /**
754 * Generate HTML for respondent info section in email.
755 *
756 * @param array $respondent_info Array with 'name', 'email', 'avatar' keys.
757 * @return string HTML for respondent info section.
758 */
759 private static function generate_respondent_info_html( $respondent_info ) {
760 if ( empty( $respondent_info ) ) {
761 return '';
762 }
763
764 $name = isset( $respondent_info['name'] ) ? esc_html( $respondent_info['name'] ) : '';
765 $email = isset( $respondent_info['email'] ) ? esc_html( $respondent_info['email'] ) : '';
766 $avatar = isset( $respondent_info['avatar'] ) ? esc_url( $respondent_info['avatar'] ) : '';
767
768 // Don't show section if there's no name or email.
769 if ( empty( $name ) && empty( $email ) ) {
770 return '';
771 }
772
773 // Get initials for avatar fallback.
774 $initials = '';
775 if ( ! empty( $name ) ) {
776 $name_parts = explode( ' ', $name );
777 $initials = strtoupper( substr( $name_parts[0], 0, 1 ) );
778 if ( count( $name_parts ) > 1 ) {
779 $initials .= strtoupper( substr( end( $name_parts ), 0, 1 ) );
780 }
781 } elseif ( ! empty( $email ) ) {
782 $initials = strtoupper( substr( $email, 0, 1 ) );
783 }
784
785 // Avatar content - either image or initials.
786 $avatar_content = ! empty( $avatar )
787 ? '<img src="' . $avatar . '" alt="" width="48" height="48" style="border-radius: 24px; vertical-align: middle;">'
788 : esc_html( $initials );
789
790 // Use table layout for maximum email client compatibility.
791 $html = '
792 <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="respondent-table" width="100%" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; margin-bottom: 16px;">
793 <tr>
794 <td class="respondent-avatar-cell" style="width: 64px; vertical-align: middle; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
795 <!--[if mso]>
796 <table role="presentation" border="0" cellpadding="0" cellspacing="0" width="48" height="48" style="width: 48px; height: 48px;">
797 <tr>
798 <td align="center" valign="middle" style="width: 48px; height: 48px; background-color: #f0f0f0; border-radius: 24px; font-size: 18px; font-weight: 600; color: #50575e;">
799 <![endif]-->
800 <div class="respondent-avatar-wrapper" style="width: 48px; height: 48px; border-radius: 24px; background-color: #f0f0f0; text-align: center; line-height: 48px; font-size: 18px; font-weight: 600; color: #50575e;">
801 ' . $avatar_content . '
802 </div>
803 <!--[if mso]>
804 </td>
805 </tr>
806 </table>
807 <![endif]-->
808 </td>
809 <td class="respondent-details-cell" style="vertical-align: middle; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif;">
810 ' . ( ! empty( $name ) ? '<div class="respondent-name" style="font-size: ' . self::FONT_SIZE_FIELD_VALUE . '; font-weight: 600; color: ' . self::TEXT_COLOR . '; margin: 0 0 2px 0; line-height: 1.4;">' . $name . '</div>' : '' ) . '
811 ' . ( ! empty( $email ) ? '<div class="respondent-email" style="font-size: ' . self::FONT_SIZE_BUTTON . '; margin: 0; line-height: 1.4;"><a href="mailto:' . $email . '" style="color: ' . self::TEXT_SECONDARY_COLOR . '; text-decoration: underline;">' . $email . '</a></div>' : '' ) . '
812 </td>
813 </tr>
814 </table>';
815
816 return str_replace( "\t", '', $html );
817 }
818
819 /**
820 * Generate HTML for metadata section in email.
821 *
822 * @param array $metadata Array with 'date', 'source', 'source_url', 'device', 'ip', 'ip_flag', 'logged_in_user' (with display_name, username, id) keys.
823 * @return string HTML for metadata section.
824 */
825 private static function generate_metadata_html( $metadata ) {
826 if ( empty( $metadata ) ) {
827 return '';
828 }
829
830 $rows = array();
831
832 // Date row.
833 if ( ! empty( $metadata['date'] ) ) {
834 $rows[] = self::generate_metadata_row( __( 'Date', 'jetpack-forms' ), esc_html( $metadata['date'] ) );
835 }
836
837 // Source row.
838 if ( ! empty( $metadata['source'] ) ) {
839 $source_value = esc_html( $metadata['source'] );
840 if ( ! empty( $metadata['source_url'] ) ) {
841 $source_value = '<a href="' . esc_url( $metadata['source_url'] ) . '" style="color: ' . self::LINK_COLOR . '; text-decoration: underline;">' . $source_value . '</a>';
842 }
843 $rows[] = self::generate_metadata_row( __( 'Source', 'jetpack-forms' ), $source_value );
844 }
845
846 // Device row.
847 if ( ! empty( $metadata['device'] ) ) {
848 $rows[] = self::generate_metadata_row( __( 'Device', 'jetpack-forms' ), esc_html( $metadata['device'] ) );
849 }
850
851 // IP Address row.
852 if ( ! empty( $metadata['ip'] ) ) {
853 $ip_value = '';
854 if ( ! empty( $metadata['ip_flag'] ) ) {
855 $ip_value .= $metadata['ip_flag'] . ' ';
856 }
857 $ip_value .= esc_html( $metadata['ip'] );
858 $rows[] = self::generate_metadata_row( __( 'IP address', 'jetpack-forms' ), $ip_value );
859 }
860
861 // Logged in user row.
862 if ( ! empty( $metadata['logged_in_user'] ) && isset( $metadata['logged_in_user']['id'] ) ) {
863 $user_id = $metadata['logged_in_user']['id'];
864 $user_value = '#' . $user_id;
865 if ( ! empty( $metadata['logged_in_user']['display_name'] ) ) {
866 $user_value = $metadata['logged_in_user']['display_name'] . ' (#' . $user_id . ')';
867 } elseif ( ! empty( $metadata['logged_in_user']['username'] ) ) {
868 $user_value = $metadata['logged_in_user']['username'] . ' (#' . $user_id . ')';
869 }
870 $rows[] = self::generate_metadata_row( __( 'Logged-in user', 'jetpack-forms' ), esc_html( $user_value ) );
871 }
872
873 if ( empty( $rows ) ) {
874 return '';
875 }
876
877 // Use table layout for maximum email client compatibility.
878 $html = '
879 <table role="presentation" border="0" cellpadding="0" cellspacing="0" class="metadata-table" width="100%" style="border-collapse: collapse; mso-table-lspace: 0pt; mso-table-rspace: 0pt; width: 100%; margin-bottom: 24px;">
880 ' . implode( '', $rows ) . '
881 <tr><td colspan="2" style="padding: 24px 0 0 0; border-bottom: 1px solid #E4E4E7; font-size: 0; line-height: 0;">&nbsp;</td></tr>
882 </table>';
883
884 return str_replace( "\t", '', $html );
885 }
886
887 /**
888 * Generate a single metadata row.
889 *
890 * @param string $label The label text.
891 * @param string $value The value (can contain HTML).
892 * @return string HTML for the row.
893 */
894 private static function generate_metadata_row( $label, $value ) {
895 return '
896 <tr>
897 <td class="metadata-label" style="color: #50575e; width: 100px; padding: 4px 12px 4px 0; font-size: ' . self::FONT_SIZE_METADATA . '; vertical-align: top; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif; line-height: 1.4;">' . esc_html( $label ) . ':</td>
898 <td class="metadata-value" style="color: #1e1e1e; padding: 4px 0; font-size: ' . self::FONT_SIZE_METADATA . '; vertical-align: top; font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, Oxygen-Sans, Ubuntu, Cantarell, \'Helvetica Neue\', sans-serif; line-height: 1.4;">' . $value . '</td>
899 </tr>';
900 }
901
902 /**
903 * Minify a CSS string by removing comments, collapsing whitespace,
904 * and stripping unnecessary characters.
905 *
906 * Gmail imposes an 8,192-character limit across all <style> blocks.
907 * This keeps the template file readable while fitting under the limit.
908 *
909 * @param string $css The CSS string (may include <style> tags).
910 * @return string The minified CSS string.
911 */
912 private static function minify_css( $css ) {
913 // Remove CSS comments.
914 $css = preg_replace( '/\/\*.*?\*\//s', '', $css );
915 // Collapse all whitespace (tabs, newlines, spaces) into single spaces.
916 $css = preg_replace( '/\s+/', ' ', $css );
917 // Remove spaces around CSS punctuation: { } ; : ,
918 $css = preg_replace( '/\s*([{};,])\s*/', '$1', $css );
919 // Remove space after colons in all contexts.
920 $css = preg_replace( '/:\s+/', ':', $css );
921 // Remove trailing semicolons before closing braces.
922 $css = str_replace( ';}', '}', $css );
923 return trim( $css );
924 }
925 }
926