Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
64.03% covered (warning)
64.03%
986 / 1540
37.50% covered (danger)
37.50%
30 / 80
CRAP
0.00% covered (danger)
0.00%
0 / 1
Contact_Form
64.15% covered (warning)
64.15%
986 / 1537
37.50% covered (danger)
37.50%
30 / 80
14127.16
0.00% covered (danger)
0.00%
0 / 1
 set_ref_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 clear_ref_id
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 get_ref_id
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 has_seen
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
2
 reset_seen_refs
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 render_synced_form
80.00% covered (warning)
80.00%
12 / 15
0.00% covered (danger)
0.00%
0 / 1
7.39
 render_synced_form_content
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 render_frontend_status_notice
0.00% covered (danger)
0.00%
0 / 37
0.00% covered (danger)
0.00%
0 / 1
42
 __construct
93.42% covered (success)
93.42%
71 / 76
0.00% covered (danger)
0.00%
0 / 1
18.09
 get_instance_from_jwt
69.74% covered (warning)
69.74%
53 / 76
0.00% covered (danger)
0.00%
0 / 1
33.22
 set_source
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 set_is_preview_submission
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 is_preview_submission
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 get_context
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
8.06
 increment_form_context_count
60.00% covered (warning)
60.00%
3 / 5
0.00% covered (danger)
0.00%
0 / 1
2.26
 register_post_type
100.00% covered (success)
100.00%
69 / 69
100.00% covered (success)
100.00%
1 / 1
1
 get_forms_count
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 compute_id
100.00% covered (success)
100.00%
8 / 8
100.00% covered (success)
100.00%
1 / 1
3
 get_secret
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 get_default_thank_you_heading
28.57% covered (danger)
28.57%
2 / 7
0.00% covered (danger)
0.00%
0 / 1
6.28
 get_attributes
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_jwt
91.38% covered (success)
91.38%
53 / 58
0.00% covered (danger)
0.00%
0 / 1
9.05
 get_source
50.00% covered (danger)
50.00%
2 / 4
0.00% covered (danger)
0.00%
0 / 1
2.50
 get_forms_context_count
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_default_to
90.91% covered (success)
90.91%
10 / 11
0.00% covered (danger)
0.00%
0 / 1
5.02
 get_default_to_for_editor
92.31% covered (success)
92.31%
12 / 13
0.00% covered (danger)
0.00%
0 / 1
6.02
 get_post_property
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
6
 get_default_subject
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
6
 store_shortcode
n/a
0 / 0
n/a
0 / 0
1
 style
0.00% covered (danger)
0.00%
0 / 3
0.00% covered (danger)
0.00%
0 / 1
2
 style_on
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 add_quick_link_to_admin_bar
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
6
 parse
72.91% covered (warning)
72.91%
148 / 203
0.00% covered (danger)
0.00%
0 / 1
203.12
 prepare_submit_button
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
6.05
 add_submit_button_interactivity_attributes
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 render_noscript_success_message
0.00% covered (danger)
0.00%
0 / 17
0.00% covered (danger)
0.00%
0 / 1
12
 format_submission_data
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
30
 get_url
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
56
 get_rating
0.00% covered (danger)
0.00%
0 / 14
0.00% covered (danger)
0.00%
0 / 1
56
 get_field_type_icon
95.00% covered (success)
95.00%
19 / 20
0.00% covered (danger)
0.00%
0 / 1
6
 render_error_wrapper
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 render_ajax_success_wrapper
23.30% covered (danger)
23.30%
24 / 103
0.00% covered (danger)
0.00%
0 / 1
408.46
 success_message
100.00% covered (success)
100.00%
25 / 25
100.00% covered (success)
100.00%
1 / 1
4
 get_compiled_form
73.68% covered (warning)
73.68%
14 / 19
0.00% covered (danger)
0.00%
0 / 1
5.46
 get_json_data
n/a
0 / 0
n/a
0 / 0
3
 get_raw_compiled_form_data
66.67% covered (warning)
66.67%
4 / 6
0.00% covered (danger)
0.00%
0 / 1
3.33
 get_compiled_form_for_email
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 escape_and_sanitize_field_value
100.00% covered (success)
100.00%
24 / 24
100.00% covered (success)
100.00%
1 / 1
18
 remove_empty
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 get_file_upload_fields
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
20
 delete_feedback_files
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 esc_shortcode_val
100.00% covered (success)
100.00%
13 / 13
100.00% covered (success)
100.00%
1 / 1
3
 parse_contact_field
83.02% covered (warning)
83.02%
44 / 53
0.00% covered (danger)
0.00%
0 / 1
35.71
 is_file_upload_field
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
5
 get_default_label_from_type
44.68% covered (danger)
44.68%
21 / 47
0.00% covered (danger)
0.00%
0 / 1
65.92
 get_field_ids
72.97% covered (warning)
72.97%
27 / 37
0.00% covered (danger)
0.00%
0 / 1
16.34
 process_submission
83.98% covered (warning)
83.98%
152 / 181
0.00% covered (danger)
0.00%
0 / 1
76.30
 has_custom_redirect
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_redirect_url
88.89% covered (warning)
88.89%
16 / 18
0.00% covered (danger)
0.00%
0 / 1
6.05
 get_permalink
80.00% covered (warning)
80.00%
4 / 5
0.00% covered (danger)
0.00%
0 / 1
3.07
 wp_mail
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_name_to_address
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_mail_content_type
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 wrap_message_in_html_tags
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 add_plain_text_alternative
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 addslashes_deep
50.00% covered (danger)
50.00%
4 / 8
0.00% covered (danger)
0.00%
0 / 1
6.00
 get_block_container_classes
83.33% covered (warning)
83.33%
5 / 6
0.00% covered (danger)
0.00%
0 / 1
3.04
 get_block_alignment_class
100.00% covered (success)
100.00%
7 / 7
100.00% covered (success)
100.00%
1 / 1
3
 process_file_upload_field
0.00% covered (danger)
0.00%
0 / 29
0.00% covered (danger)
0.00%
0 / 1
90
 maybe_transform_value
0.00% covered (danger)
0.00%
0 / 22
0.00% covered (danger)
0.00%
0 / 1
272
 get_images
0.00% covered (danger)
0.00%
0 / 16
0.00% covered (danger)
0.00%
0 / 1
42
 get_files
0.00% covered (danger)
0.00%
0 / 18
0.00% covered (danger)
0.00%
0 / 1
42
 escape_and_sanitize_field_label
66.67% covered (warning)
66.67%
2 / 3
0.00% covered (danger)
0.00%
0 / 1
2.15
 add_theme_json_data_for_classic_themes
0.00% covered (danger)
0.00%
0 / 59
0.00% covered (danger)
0.00%
0 / 1
12
 validate
90.00% covered (success)
90.00%
9 / 10
0.00% covered (danger)
0.00%
0 / 1
7.05
 validate_ref
71.43% covered (warning)
71.43%
5 / 7
0.00% covered (danger)
0.00%
0 / 1
4.37
 reset_errors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 add_error
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
2
 has_errors
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 get_error_messages
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 get_confirmation_type
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 get_disable_summary
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
1<?php
2/**
3 * Contact_Form class.
4 *
5 * @package automattic/jetpack-forms
6 */
7
8namespace Automattic\Jetpack\Forms\ContactForm;
9
10use Automattic\Jetpack\Connection\Tokens;
11use Automattic\Jetpack\Forms\Dashboard\Dashboard as Forms_Dashboard;
12use Automattic\Jetpack\JWT;
13use Automattic\Jetpack\Sync\Settings;
14use PHPMailer\PHPMailer\PHPMailer;
15use WP_Block;
16use WP_Error;
17use WP_Post;
18
19// Load the Form_Submission_Error class.
20require_once __DIR__ . '/class-form-submission-error.php';
21
22if ( ! defined( 'ABSPATH' ) ) {
23    exit( 0 );
24}
25
26/**
27 * Class for the contact-form shortcode.
28 * Parses shortcode to output the contact form as HTML
29 * Sends email and stores the contact form response (a.k.a. "feedback")
30 */
31class Contact_Form extends Contact_Form_Shortcode {
32
33    /**
34     * The shortcode name.
35     *
36     * @var string
37     */
38    public $shortcode_name = 'contact-form';
39
40    /**
41     * The custom post type for forms.
42     *
43     * @var string
44     */
45    const POST_TYPE = 'jetpack_form';
46
47    /**
48     * Meta key for the source post ID.
49     *
50     * @var string
51     */
52    const SOURCE_META_KEY = '_jetpack_forms_source_post_id';
53
54    /**
55     *
56     * Stores form submission errors.
57     *
58     * @var WP_Error
59     */
60    public $errors;
61
62    /**
63     * The SHA1 hash of the attributes that comprise the form.
64     *
65     * @var string
66     */
67    public $hash;
68
69    /**
70     * The most recent (inclusive) contact-form shortcode processed.
71     *
72     * @var Contact_Form|null
73     */
74    public static $last;
75
76    /**
77     * Form we are currently looking at. If processed, will become $last
78     *
79     * @var Contact_Form|null
80     */
81    public static $current_form;
82
83    /**
84     * All found forms, indexed by hash.
85     *
86     * @var array
87     */
88    public static $forms = array();
89
90    /**
91     * The context for the forms, indexed by context.
92     * This is used to keep track of how many forms are in a specific context.
93     *
94     * @var array
95     */
96    public static $forms_context = array();
97
98    /**
99     * Array of WP_Error objects that are keyed by form id.
100     *
101     * @var array
102     */
103    public static $static_errors = array();
104
105    /**
106     * Whether to print the grunion.css style when processing the contact-form shortcode
107     *
108     * @var bool
109     */
110    public static $style = false;
111
112    /**
113     * When printing the submit button, what tags are allowed
114     *
115     * @var array
116     */
117    public static $allowed_html_tags_for_submit_button = array( 'br' => array() );
118
119    /**
120     * Whether to enable response without reloading the page.
121     *
122     * @var bool
123     */
124    public $is_response_without_reload_enabled = true;
125
126    /**
127     * The current post object for this form.
128     *
129     * @var WP_Post|null
130     */
131    public $current_post;
132
133    /**
134     * Whether the form has a verified JWT token.
135     *
136     * @var bool
137     */
138    public $has_verified_jwt = false;
139
140    /**
141     * Whether the current submission originated from an authenticated form preview.
142     *
143     * When true, the resulting feedback is marked as a test submission — Akismet
144     * is skipped, the notification email is annotated, and the response is
145     * excluded from the default CSV export.
146     *
147     * @var bool
148     */
149    private $is_preview_submission = false;
150
151    /**
152     * The source of the feedback entry.
153     *
154     * @var Feedback_Source
155     */
156    private $source;
157
158    /**
159     * The reference ID for the contact form.
160     *
161     * @var int|null
162     */
163    private static $ref_id = null;
164
165    /**
166     * Seen reference IDs for the contact form.
167     *
168     * @var array
169     */
170    private static $seen_ref = array();
171
172    /**
173     * Set the reference ID for the contact form.
174     *
175     * @param int $ref_id The reference ID.
176     */
177    public static function set_ref_id( $ref_id ) {
178        self::$ref_id              = $ref_id;
179        self::$seen_ref[ $ref_id ] = true;
180    }
181
182    /**
183     * Clear the reference ID for the contact form.
184     *
185     * @param int $ref_id The reference ID to clear.
186     */
187    public static function clear_ref_id( $ref_id ) {
188        self::$ref_id              = null;
189        self::$seen_ref[ $ref_id ] = false;
190    }
191
192    /**
193     * Get the reference ID for the contact form.
194     *
195     * @return int|null The reference ID.
196     */
197    public static function get_ref_id() {
198        return self::$ref_id;
199    }
200
201    /**
202     * Check if the reference ID has been seen for the contact form.
203     *
204     * @param int $ref_id The reference ID.
205     * @return bool True if the reference ID has been seen, false otherwise.
206     */
207    public static function has_seen( $ref_id ) {
208        return isset( self::$seen_ref[ $ref_id ] ) && self::$seen_ref[ $ref_id ];
209    }
210
211    /**
212     * Reset the seen reference IDs for the contact form.
213     */
214    public static function reset_seen_refs() {
215        self::$seen_ref = array();
216        self::$ref_id   = null;
217    }
218
219    /**
220     * Render a synced form by reference ID.
221     *
222     * This handles loading a form from a jetpack_form post and rendering it.
223     * Used by both the shortcode [contact-form ref="123"] and the block.
224     *
225     * @param int $ref_id The jetpack_form post ID.
226     * @return string Rendered form HTML.
227     */
228    public static function render_synced_form( $ref_id ) {
229        // Circular reference prevention.
230        if ( self::has_seen( $ref_id ) ) {
231            return '';
232        }
233
234        // Load the jetpack_form post.
235        $synced_form = get_post( $ref_id );
236
237        // Validate post.
238        if ( ! $synced_form || self::POST_TYPE !== $synced_form->post_type ) {
239            return '';
240        }
241
242        $status = $synced_form->post_status;
243
244        // Trashed forms are always hidden.
245        if ( 'trash' === $status ) {
246            return '';
247        }
248
249        // Published forms render normally for everyone.
250        if ( 'publish' === $status ) {
251            return self::render_synced_form_content( $ref_id, $synced_form );
252        }
253
254        // For non-published statuses (draft, pending, future, private), only show preview to users who can edit the form.
255        if ( ! current_user_can( 'edit_post', $ref_id ) ) {
256            return '';
257        }
258
259        // Render the form with a status notice for editors.
260        $notice       = self::render_frontend_status_notice( $synced_form );
261        $form_content = self::render_synced_form_content( $ref_id, $synced_form );
262
263        return $notice . $form_content;
264    }
265
266    /**
267     * Render the actual form content for a synced form.
268     *
269     * @param int      $ref_id The jetpack_form post ID.
270     * @param \WP_Post $synced_form The synced form post object.
271     * @return string Rendered form HTML.
272     */
273    private static function render_synced_form_content( $ref_id, $synced_form ) {
274        if ( $ref_id === self::get_ref_id() ) {
275            return '';
276        }
277        // Mark as seen for circular reference prevention.
278        self::set_ref_id( $ref_id );
279        $output = '';
280        try {
281            // Parse and render blocks from post_content.
282            $blocks = parse_blocks( $synced_form->post_content );
283            foreach ( $blocks as $block ) {
284                $output .= render_block( $block );
285            }
286        } finally {
287            // Clean up.
288            self::clear_ref_id( $ref_id );
289        }
290        return $output;
291    }
292
293    /**
294     * Render a frontend status notice for non-published forms.
295     *
296     * @param \WP_Post $synced_form The synced form post object.
297     * @return string Notice HTML.
298     */
299    private static function render_frontend_status_notice( $synced_form ) {
300        $status = $synced_form->post_status;
301
302        if ( 'publish' === $status || 'private' === $status ) {
303            return '';
304        }
305
306        $status_config = array(
307            'draft'   => array(
308                'type'    => 'warning',
309                'message' => __( 'This form is a draft and is only visible to you. Publish it to make it visible to site visitors.', 'jetpack-forms' ),
310            ),
311            'pending' => array(
312                'type'    => 'warning',
313                'message' => __( 'This form is pending review and is only visible to you. It will be visible to site visitors once approved and published.', 'jetpack-forms' ),
314            ),
315            'future'  => array(
316                'type'    => 'info',
317                'message' => sprintf(
318                    /* translators: %s: scheduled publish date */
319                    __( 'This form is scheduled for %s and is only visible to you until then.', 'jetpack-forms' ),
320                    wp_date( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ), get_post_time( 'U', true, $synced_form ) )
321                ),
322            ),
323        );
324
325        if ( ! isset( $status_config[ $status ] ) ) {
326            return '';
327        }
328
329        wp_enqueue_style( 'jetpack-form-status-notice' );
330
331        $config     = $status_config[ $status ];
332        $type_class = 'info' === $config['type'] ? 'jetpack-form-status-notice--info' : 'jetpack-form-status-notice--warning';
333        $edit_url   = get_edit_post_link( $synced_form->ID, 'raw' );
334        $edit_link  = $edit_url ? sprintf(
335            ' <a href="%s" class="jetpack-form-status-notice__edit-link">%s</a>',
336            esc_url( $edit_url ),
337            esc_html__( 'Edit form', 'jetpack-forms' )
338        ) : '';
339
340        return sprintf(
341            '<div class="jetpack-form-status-notice %s"><p>%s%s</p></div>',
342            esc_attr( $type_class ),
343            esc_html( $config['message'] ),
344            $edit_link
345        );
346    }
347
348    /**
349     * Construction function.
350     *
351     * @param array  $attributes - the attributes.
352     * @param string $content - the content.
353     * @param bool   $set_id - whether to set the ID for the form.
354     */
355    public function __construct( $attributes, $content = null, $set_id = true ) {
356        global $post, $page;
357
358        // AJAX requests don't have a post object, so we need to get the post object from the $_POST['contact-form-id']
359        $this->current_post = $post;
360
361        // phpcs:disable WordPress.Security.NonceVerification.Missing -- Nonce verification happens in process_form_submission() for logged-in users
362        if ( ! $this->current_post && isset( $_POST['contact-form-id'] ) ) {
363            $contact_form_id    = sanitize_text_field( wp_unslash( $_POST['contact-form-id'] ) );
364            $this->current_post = get_post( $contact_form_id );
365        }
366        // phpcs:enable
367
368        $this->is_response_without_reload_enabled = apply_filters( 'jetpack_forms_enable_ajax_submission', true );
369
370        // Initialize the source before setting defaults
371        if ( ! $this->source ) {
372            $attributes   = is_array( $attributes ) ? $attributes : array();
373            $this->source = Feedback_Source::get_current( $attributes );
374        }
375
376        // Set up the default subject and recipient for this form.
377        $post_author_id  = self::get_post_property( $this->current_post, 'post_author' );
378        $default_to      = self::get_default_to( $post_author_id, $this->source );
379        $default_subject = self::get_default_subject( $attributes, $this->current_post );
380
381        if ( ! isset( $attributes ) || ! is_array( $attributes ) ) {
382            $attributes = array();
383        }
384
385        if ( $set_id ) {
386            $page_number      = is_numeric( $page ) ? intval( $page ) : 1;
387            $attributes['id'] = self::compute_id( $attributes, $this->current_post, $page_number );
388        }
389        $this->hash = sha1(
390            wp_json_encode(
391                $attributes,
392                0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because we don't want to disrupt the current hash index.
393            )
394        );
395
396        if ( $set_id ) {
397            self::$forms[ $this->hash ] = $this; // This increments the form count.
398            self::increment_form_context_count( $attributes, $this->current_post );
399        }
400
401        // Keep reference to $this for parsing form fields.
402        self::$current_form = $this;
403
404        $this->defaults = array(
405            'to'                     => $default_to,
406            'subject'                => $default_subject,
407            'show_subject'           => 'no', // only used in back-compat mode
408            'widget'                 => 0,    // Not exposed to the user. Works with Contact_Form_Plugin::widget_atts()
409            'block_template'         => null, // Not exposed to the user. Works with template_loader
410            'block_template_part'    => null, // Not exposed to the user. Works with Contact_Form::parse()
411            'id'                     => null, // Not exposed to the user. Set above.
412            'ref'                    => null, // Not exposed to the user. Set above if applicable.
413            'submit_button_text'     => __( 'Submit', 'jetpack-forms' ),
414            // These attributes come from the block editor, so use camel case instead of snake case.
415            'customThankyou'         => '', // Whether to show a custom thankyou response after submitting a form. '' for no, 'noSummary' to disable the summary, 'message' for a custom message, 'redirect' to redirect to a new URL. Deprecated.
416            'customThankyouHeading'  => self::get_default_thank_you_heading(), // The text to show above customThankyouMessage.
417            'customThankyouMessage'  => '', // The message to show when customThankyou is set to 'message'.
418            'customThankyouRedirect' => '', // The URL to redirect to when confirmationType is set to 'redirect'.
419            'confirmationType'       => 'text', // The type of confirmation to show after submitting a form. 'text' for a text message, 'redirect' for a redirect link.
420            'jetpackCRM'             => true, // Whether Jetpack CRM should store the form submission.
421            'mailpoet'               => null,
422            'hostingerReach'         => null,
423            'className'              => null,
424            'postToUrl'              => null,
425            'salesforceData'         => null,
426            'hiddenFields'           => null,
427            'stepTransition'         => 'fade-slide', // The transition style for multi-step forms. Options: none, fade, slide, fade-slide
428            'saveResponses'          => 'yes',
429            'emailNotifications'     => 'yes',
430            'notificationRecipients' => array(), // Array of user IDs who should receive form response notifications.
431            'webhooks'               => array(), // Array of webhooks to send the form data to.
432            'disableGoBack'          => $attributes['disableGoBack'] ?? false,
433            'disableSummary'         => $attributes['disableSummary'] ?? false,
434            'formTitle'              => $attributes['formTitle'] ?? '',
435        );
436
437        $attributes = shortcode_atts( $this->defaults, $attributes, 'contact-form' );
438
439        // Transform boolean saveResponses to string for backend compatibility
440        if ( isset( $attributes['saveResponses'] ) && is_bool( $attributes['saveResponses'] ) ) {
441            $attributes['saveResponses'] = $attributes['saveResponses'] ? 'yes' : 'no';
442        }
443
444        // Transform boolean emailNotifications to string for backend compatibility
445        if ( isset( $attributes['emailNotifications'] ) && is_bool( $attributes['emailNotifications'] ) ) {
446            $attributes['emailNotifications'] = $attributes['emailNotifications'] ? 'yes' : 'no';
447        }
448
449        // We only enable the contact-field shortcode temporarily while processing the contact-form shortcode.
450        Contact_Form_Plugin::$using_contact_form_field = true;
451
452        parent::__construct( $attributes, $content );
453
454        // There were no fields in the contact form. The form was probably just [contact-form /]. Build a default form.
455        if ( empty( $this->fields ) ) {
456            // same as the original Grunion v1 form.
457            $default_form = '
458                [contact-field label="' . __( 'Name', 'jetpack-forms' ) . '" type="name"  required="true" /]
459                [contact-field label="' . __( 'Email', 'jetpack-forms' ) . '" type="email" required="true" /]
460                [contact-field label="' . __( 'Website', 'jetpack-forms' ) . '" type="url" /]';
461
462            if ( 'yes' === strtolower( $this->get_attribute( 'show_subject' ) ) ) {
463                $default_form .= '
464                    [contact-field label="' . __( 'Subject', 'jetpack-forms' ) . '" type="subject" /]';
465            }
466
467            $default_form .= '
468                [contact-field label="' . __( 'Message', 'jetpack-forms' ) . '" type="textarea" /]';
469
470            $this->parse_content( $default_form );
471        }
472
473        // $this->body and $this->fields have been setup.  We no longer need the contact-field shortcode.
474        Contact_Form_Plugin::$using_contact_form_field = false;
475    }
476    /**
477     * Get the instance of the contact form from a JWT token.
478     *
479     * @param string $jwt_token The JWT token.
480     * @param bool   $throw_exception Whether to throw an exception if the JWT token is invalid or cannot be decoded.
481     *
482     * @return Contact_Form|null The contact form instance, or null if decoding fails and $throw_exception is false.
483     * @throws \Exception If the JWT token is invalid or cannot be decoded and $throw_exception is true.
484     */
485    public static function get_instance_from_jwt( $jwt_token, $throw_exception = false ) {
486        $secret = self::get_secret();
487
488        // Derive separate keys using HKDF for proper key separation and context binding
489        $jwt_signing_key = hash_hkdf( 'sha256', $secret, 32, 'jetpack-forms-jwt-hmac-v2' );
490        $encryption_key  = hash_hkdf( 'sha256', $secret, 32, 'jetpack-forms-aes-gcm-v2' );
491
492        try {
493            $data = JWT::decode( $jwt_token, $jwt_signing_key, array( 'HS256' ), true );
494        } catch ( \Exception $e ) {
495            try {
496                // Retry to decode the token using the secret key instead of the derived key
497                $data = JWT::decode( $jwt_token, $secret, array( 'HS256' ), true );
498            } catch ( \Exception $e ) {
499                // Re-throw with more context about the failure.
500                if ( $throw_exception ) {
501                    /**
502                     * Filter the failure to decode a JWT token for a contact form.
503                     *
504                     * @param null $value The value to return. Default null.
505                     * @param string      $jwt_token The JWT token that failed to decode.
506                     * @param \Exception  $e The exception that was thrown during decoding.
507                     *
508                     * @return Contact_Form|null The value to return.
509                     */
510                    $filtered = apply_filters( 'jetpack_forms_jwt_decode_failure', null, $jwt_token, $e );
511                    if ( $filtered !== null ) {
512                        return $filtered;
513                    }
514                    throw new \Exception(
515                        sprintf(
516                            /* translators: %s is the original exception message */
517                            __( 'Failed to decode JWT token: %s', 'jetpack-forms' ),
518                            $e->getMessage()
519                        ),
520                        0,
521                        $e
522                    );
523                }
524                return apply_filters( 'jetpack_forms_jwt_decode_failure', null, $jwt_token, $e );
525            }
526        }
527
528        $version = isset( $data['version'] ) ? absint( $data['version'] ) : 1;
529
530        if ( 2 === $version ) {
531            if ( ! isset( $data['encrypted_attributes'] ) ) {
532                throw new \Exception( 'Invalid JWT format - encrypted attributes required' );
533            }
534
535            // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_decode -- Base64 decoding required for encrypted data
536            $encrypted_blob = base64_decode( $data['encrypted_attributes'], true ); // Strict mode
537            if ( $encrypted_blob === false ) {
538                throw new \Exception( 'Invalid base64 encoding in encrypted data' );
539            }
540
541            // Determine which cipher was used (stored in JWT or default to GCM)
542            $cipher = $data['cipher'] ?? 'aes-256-gcm';
543
544            // Check if the cipher is available on this server
545            $available_cipher_methods = array_map( 'strtolower', openssl_get_cipher_methods() );
546            if ( ! in_array( strtolower( $cipher ), $available_cipher_methods, true ) ) {
547                throw new \Exception( 'Required encryption cipher ' . $cipher . ' is not available on this server' );
548            }
549
550            // Determine IV and tag sizes based on cipher
551            $is_gcm = stripos( $cipher, 'gcm' ) !== false;
552            if ( $is_gcm ) {
553                // GCM: 12-byte IV + 16-byte tag + ciphertext
554                if ( strlen( $encrypted_blob ) < 29 ) { // 12 + 16 + at least 1 byte
555                    throw new \Exception( 'Invalid encrypted data format - too short for GCM' );
556                }
557                $iv        = substr( $encrypted_blob, 0, 12 );  // 12-byte IV (96-bit)
558                $tag       = substr( $encrypted_blob, 12, 16 ); // 16-byte auth tag
559                $encrypted = substr( $encrypted_blob, 28 );     // Remaining ciphertext
560            } else {
561                // CBC: 16-byte IV + ciphertext (no tag)
562                if ( strlen( $encrypted_blob ) < 17 ) { // 16 + at least 1 byte
563                    throw new \Exception( 'Invalid encrypted data format - too short for CBC' );
564                }
565                $iv        = substr( $encrypted_blob, 0, 16 );  // 16-byte IV (128-bit)
566                $tag       = null; // No tag for CBC
567                $encrypted = substr( $encrypted_blob, 16 );     // Remaining ciphertext
568            }
569
570            $decrypted = openssl_decrypt(
571                $encrypted,
572                $cipher,
573                $encryption_key,
574                OPENSSL_RAW_DATA, // Expect raw binary data
575                $iv,
576                $tag ?? ''
577            );
578
579            if ( $decrypted === false ) {
580                throw new \Exception( 'Decryption failed - invalid token' );
581            }
582
583            $decrypted_attributes = json_decode( $decrypted, true );
584            if ( $decrypted_attributes === null ) {
585                throw new \Exception( 'Invalid attributes format' );
586            }
587
588            // Reconstruct data with decrypted attributes and unencrypted fields
589            $data['attributes'] = $decrypted_attributes;
590            // content, hash, and source are already in $data (unencrypted)
591        } elseif ( ! in_array( $version, array( 0, 1 ), true ) ) {
592            throw new \Exception( 'Unsupported JWT version' );
593        }
594
595        $source = $data['source'] ?? array();
596
597        if ( empty( $source ) ) {
598            // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission()
599            $source_post_id = ! empty( $_POST['contact-form-id'] ) && is_numeric( $_POST['contact-form-id'] ) ? absint( wp_unslash( $_POST['contact-form-id'] ) ) : 0;
600            $post           = get_post( $source_post_id );
601
602            if ( $post !== null && $source_post_id > 0 ) {
603                // create a fallback source
604                $source = array(
605                    'source_id'   => $post->ID,
606                    'entry_title' => html_entity_decode( $post->post_title, ENT_QUOTES | ENT_HTML5, 'UTF-8' ),
607                    'entry_page'  => 1,
608                    'source_type' => 'single',
609                    'request_url' => get_permalink( $post ),
610                );
611            }
612        }
613
614        $form                   = new self( $data['attributes'], $data['content'], empty( $data['attributes']['id'] ) );
615        $form->source           = Feedback_Source::from_serialized( $source );
616        $form->hash             = $data['hash'];
617        $form->has_verified_jwt = true;
618
619        return $form;
620    }
621
622    /**
623     * Set the source object for the contact form.
624     *
625     * @param Feedback_Source $source The source object.
626     *
627     * @return void
628     */
629    public function set_source( $source ) {
630        $this->source = $source;
631    }
632
633    /**
634     * Flag whether the current submission originated from an authenticated form preview.
635     *
636     * @param bool $is_preview_submission Whether the submission came from form preview.
637     * @return void
638     */
639    public function set_is_preview_submission( $is_preview_submission ) {
640        $this->is_preview_submission = (bool) $is_preview_submission;
641    }
642
643    /**
644     * Whether the current submission is a test submission coming from form preview.
645     *
646     * @return bool
647     */
648    public function is_preview_submission() {
649        return $this->is_preview_submission;
650    }
651
652    /**
653     * Get the context for the contact form based on the attributes and post.
654     *
655     * @param array        $attributes The attributes of the contact form.
656     * @param WP_Post|null $post The post object, if available.
657     *
658     * @return string The context for the contact form.
659     */
660    public static function get_context( $attributes, $post = null ) {
661        $context = 'jp-form';
662        if ( ! empty( $attributes['widget'] ) && $attributes['widget'] ) {
663            $context = 'widget-' . $attributes['widget'];
664        } elseif ( ! empty( $attributes['block_template'] ) && $attributes['block_template'] ) {
665            $context = 'block-template-' . $attributes['block_template'];
666        } elseif ( ! empty( $attributes['block_template_part'] ) && $attributes['block_template_part'] ) {
667            $context = 'block-template-part-' . $attributes['block_template_part'];
668        } elseif ( $post instanceof WP_Post ) {
669            $context = (string) $post->ID;
670        }
671
672        return $context;
673    }
674
675    /**
676     * Increment the count of forms for a specific context.
677     *
678     * @param array        $attributes The attributes of the contact form.
679     * @param WP_Post|null $post The post object, if available.
680     *
681     * @return void
682     */
683    public static function increment_form_context_count( $attributes, $post ) {
684        $context = self::get_context( $attributes, $post );
685        if ( ! isset( self::$forms_context[ $context ] ) ) {
686            self::$forms_context[ $context ] = 1;
687            return;
688        }
689        self::$forms_context[ $context ] = self::get_forms_context_count( $context ) + 1;
690    }
691
692    /**
693     * Register the jetpack_form custom post type.
694     */
695    public static function register_post_type() {
696
697        $labels = array(
698            'name'                     => __( 'Forms', 'jetpack-forms' ),
699            'singular_name'            => __( 'Form', 'jetpack-forms' ),
700            'add_new'                  => __( 'Add Form', 'jetpack-forms' ),
701            'add_new_item'             => __( 'Add Form', 'jetpack-forms' ),
702            'new_item'                 => __( 'New Form', 'jetpack-forms' ),
703            'edit_item'                => __( 'Edit Block Form', 'jetpack-forms' ),
704            'view_item'                => __( 'View Form', 'jetpack-forms' ),
705            'view_items'               => __( 'View Forms', 'jetpack-forms' ),
706            'all_items'                => __( 'All Forms', 'jetpack-forms' ),
707            'search_items'             => __( 'Search Forms', 'jetpack-forms' ),
708            'not_found'                => __( 'No forms found.', 'jetpack-forms' ),
709            'not_found_in_trash'       => __( 'No forms found in Trash.', 'jetpack-forms' ),
710            'filter_items_list'        => __( 'Filter forms list', 'jetpack-forms' ),
711            'items_list_navigation'    => __( 'Forms list navigation', 'jetpack-forms' ),
712            'items_list'               => __( 'Forms list', 'jetpack-forms' ),
713            'item_published'           => __( 'Form published.', 'jetpack-forms' ),
714            'item_published_privately' => __( 'Form published privately.', 'jetpack-forms' ),
715            'item_reverted_to_draft'   => __( 'Form reverted to draft.', 'jetpack-forms' ),
716            'item_scheduled'           => __( 'Form scheduled.', 'jetpack-forms' ),
717            'item_updated'             => __( 'Form updated.', 'jetpack-forms' ),
718        );
719
720        $capabilities = array(
721            // You need to be able to edit posts, in order to read blocks in their raw form.
722            'read'                   => 'edit_posts',
723            // You need to be able to publish posts, in order to create blocks.
724            'create_posts'           => 'publish_posts',
725            'edit_posts'             => 'edit_posts',
726            'edit_published_posts'   => 'edit_published_posts',
727            'delete_published_posts' => 'delete_published_posts',
728            // Enables trashing draft posts as well.
729            'delete_posts'           => 'delete_posts',
730            'edit_others_posts'      => 'edit_others_posts',
731            'delete_others_posts'    => 'delete_others_posts',
732        );
733
734        $args = array(
735            'public'                => false,
736            'show_ui'               => true, // not sure we need this.
737            'show_in_menu'          => false,
738            'rewrite'               => false,
739            'query_var'             => false,
740            'show_in_rest'          => true,
741            'rest_base'             => 'jetpack-forms',
742            'rest_controller_class' => 'Automattic\Jetpack\Forms\ContactForm\Jetpack_Form_Endpoint',
743            'capability_type'       => 'post',
744            'capabilities'          => $capabilities,
745            'map_meta_cap'          => true,
746            'labels'                => $labels,
747            'hierarchical'          => false,
748            'template'              => array( array( 'jetpack/contact-form' ) ),
749            'supports'              => array(
750                'title',
751                'editor',
752                'revisions',
753                'author',
754                'custom-fields',
755            ),
756        );
757
758        register_post_type( self::POST_TYPE, $args );
759
760        // Register post meta for tracking the source post that created this form.
761        register_post_meta(
762            self::POST_TYPE,
763            self::SOURCE_META_KEY,
764            array(
765                'type'              => 'integer',
766                'single'            => true,
767                'show_in_rest'      => true,
768                'sanitize_callback' => 'absint',
769                'auth_callback'     => function () {
770                    return current_user_can( 'edit_posts' );
771                },
772            )
773        );
774    }
775
776    /**
777     * Get the count of forms.
778     *
779     * @return int The count of forms.
780     */
781    public static function get_forms_count() {
782        return count( self::$forms );
783    }
784
785    /**
786     * Compute the ID for the contact form based on the attributes and post.
787     *
788     * @param array        $attributes The attributes of the contact form.
789     * @param WP_Post|null $post The post object, if available.
790     * @param int          $page_number The page number, if available.
791     *
792     * @return string The ID for the contact form.
793     */
794    public static function compute_id( $attributes, $post = null, $page_number = 1 ) {
795
796        $context = self::get_context( $attributes, $post );
797        $id_part = array( $context );
798
799        if ( self::get_forms_context_count( $context ) > 0 ) {
800            $id_part[] = self::get_forms_context_count( $context );
801        }
802
803        $page_num = max( 1, intval( $page_number ) );
804        if ( $page_num > 1 ) {
805            $id_part[] = $page_num;
806        }
807
808        return implode( '-', $id_part );
809    }
810
811    /**
812     * Helper function to get the secret from the Tokens class.
813     *
814     * @return string The secret from the Tokens class, or a default secret if not available.
815     */
816    private static function get_secret() {
817
818        /**
819         * Filter the secret used for signing contact form JWT tokens.
820         *
821         * @param string $secret Passes a empty string by default so that we can fall back to other methods if the filter is not used.
822         *
823         * @return string The secret used for signing contact form JWT tokens.
824         */
825        $secret = apply_filters( 'jetpack_forms_secret_jwt', '' );
826        if ( is_string( $secret ) && ! empty( $secret ) ) {
827            return $secret;
828        }
829
830        $token = ( new Tokens() )->get_access_token();
831
832        if ( ! empty( $token->secret ) ) {
833            return $token->secret;
834        }
835
836        $secret = get_option( 'jetpack_forms_secret_key', false );
837        if ( empty( $secret ) ) {
838            // Generate a fallback secret if we don't have one from Tokens.
839            $secret = wp_generate_password( 64, true, true );
840            update_option( 'jetpack_forms_secret_key', $secret );
841        }
842
843        return $secret;
844    }
845
846    /**
847     * Get the default thank you heading with conditional sparkle.
848     *
849     * Returns the new copy with sparkle emoji if translated, otherwise
850     * falls back to the old copy without sparkle.
851     *
852     * TEMPORARY: This method can be removed once the new copy has been translated.
853     * Replace the call with: __( 'Thank you for your response.', 'jetpack-forms' ) . ' ✨'
854     *
855     * @return string The translated heading.
856     */
857    private static function get_default_thank_you_heading() {
858        // English locales always get the new copy with sparkle.
859        if ( str_starts_with( get_locale(), 'en' ) ) {
860            return __( 'Thank you for your response.', 'jetpack-forms' ) . ' ✨';
861        }
862
863        // Check if new string has a translation by comparing with the original.
864        $original   = 'Thank you for your response.';
865        $translated = __( 'Thank you for your response.', 'jetpack-forms' );
866
867        if ( $translated !== $original ) {
868            return $translated . ' ✨';
869        }
870
871        // Fall back to old string without sparkle.
872        return __( 'Your message has been sent', 'jetpack-forms' );
873    }
874
875    /**
876     * Helper function to get the attributes of the contact form.
877     *
878     * @return array The attributes of the contact form.
879     */
880    public function get_attributes() {
881        return $this->attributes;
882    }
883
884    /**
885     * Get the JWT token for the contact form instance.
886     *
887     * @return string The JWT token.
888     * @throws \Exception If encryption fails.
889     */
890    public function get_jwt() {
891        $secret = self::get_secret();
892
893        // Derive separate keys using HKDF for proper key separation and context binding
894        $jwt_signing_key = hash_hkdf( 'sha256', $secret, 32, 'jetpack-forms-jwt-hmac-v2' );
895        $encryption_key  = hash_hkdf( 'sha256', $secret, 32, 'jetpack-forms-aes-gcm-v2' );
896
897        $attributes   = $this->attributes;
898        $this->source = Feedback_Source::get_current( $attributes );
899
900        // Only encrypt the attributes field as it contains sensitive information
901        // Content, hash, and source are not sensitive and can remain unencrypted
902
903        // Check cipher availability with fallback support
904        $available_cipher_methods = openssl_get_cipher_methods();
905        $cipher                   = null;
906        $cipher_fallback          = null;
907        $use_encryption           = false;
908        $iv_length                = 12; // Default for GCM
909
910        // Try to find AES-256-GCM first (case-insensitive search)
911        foreach ( $available_cipher_methods as $method ) {
912            if ( strtolower( $method ) === 'aes-256-gcm' ) {
913                $cipher         = $method; // Use the actual name with original casing
914                $use_encryption = true;
915                // IV length already set to 12 (NIST recommended for AES-GCM)
916                break;
917            }
918            // If AES-256-GCM not found, try fallback to AES-256-CBC
919            if ( strtolower( $method ) === 'aes-256-cbc' ) {
920                $cipher_fallback = $method; // Use the actual name with original casing
921                $use_encryption  = true;
922            }
923        }
924
925        // Use the fallback cipher if the primary cipher is not available.
926        if ( $cipher === null && $cipher_fallback !== null ) {
927            $cipher    = $cipher_fallback;
928            $iv_length = 16; // 16-byte (128-bit) IV for AES-CBC
929        }
930
931        // Lazy fallback payload in case encryption fails or is unavailable.
932        $unencrypted_payload = array(
933            'attributes' => $attributes,
934            'content'    => $this->content,
935            'hash'       => $this->hash,
936            'source'     => $this->source->serialize(),
937            // No version field = version 1 (unencrypted)
938        );
939
940        if ( $use_encryption ) {
941            $iv        = random_bytes( $iv_length );
942            $tag       = ''; // Will be populated by openssl_encrypt for GCM
943            $encrypted = openssl_encrypt(
944                wp_json_encode(
945                    $attributes,
946                    JSON_UNESCAPED_SLASHES
947                ),
948                $cipher,
949                $encryption_key,
950                OPENSSL_RAW_DATA, // Return raw binary data, not base64
951                $iv,
952                $tag
953            );
954
955            if ( $encrypted === false ) {
956                do_action( 'jetpack_forms_log', 'jwt_encryption_failed', openssl_error_string() );
957                return JWT::encode( $unencrypted_payload, $jwt_signing_key );
958            }
959            // For GCM, include the authentication tag; for CBC, tag will be empty
960            $encrypted_blob = stripos( $cipher, 'GCM' ) !== false ? $iv . $tag . $encrypted : $iv . $encrypted;
961
962            return JWT::encode(
963                array(
964                    'encrypted_attributes' => base64_encode( $encrypted_blob ), // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.obfuscation_base64_encode -- Base64 encoding required for encrypted data storage
965                    'content'              => $this->content,
966                    'hash'                 => $this->hash,
967                    'source'               => $this->source->serialize(),
968                    'version'              => 2,
969                    'cipher'               => $cipher, // Store which cipher was used
970                ),
971                $jwt_signing_key
972            );
973        }
974
975        // No encryption available - fall back to version 1 format (unencrypted)
976        return JWT::encode( $unencrypted_payload, $jwt_signing_key );
977    }
978
979    /**
980     * Get the current source obejct. That is relevent to the form and there current request.
981     *
982     * @return Feedback_Source Return the current feedback source object.
983     */
984    public function get_source() {
985        if ( ! $this->source ) {
986            $attributes   = $this->attributes;
987            $this->source = Feedback_Source::get_current( $attributes );
988        }
989        return $this->source;
990    }
991
992    /**
993     * Get the count of forms.
994     *
995     * @param string $context The context for which to get the count of forms.
996     *
997     * @return int The count of forms.
998     */
999    public static function get_forms_context_count( $context ) {
1000        if ( ! isset( self::$forms_context[ $context ] ) ) {
1001            self::$forms_context[ $context ] = 0;
1002            return 0;
1003        }
1004
1005        return self::$forms_context[ $context ];
1006    }
1007
1008    /**
1009     * Get the default recipient email address for the contact form.
1010     *
1011     * @param int|null             $post_author_id The ID of the post author. If provided, will return the author's email.
1012     * @param Feedback_Source|null $source The source of the feedback entry. Optional, not used currently.
1013     *
1014     * @return string The default recipient email address.
1015     */
1016    public static function get_default_to( $post_author_id = null, $source = null ) {
1017        // Get the default recipient email address.
1018        $default_to = get_option( 'admin_email' );
1019        // Check that the user has edit permissions for this blog and has an email address
1020        if ( ! $post_author_id ) {
1021            return $default_to;
1022        }
1023
1024        // Check that source is of type Feedback_Source
1025        if ( ! $source instanceof Feedback_Source ) {
1026            return $default_to;
1027        }
1028
1029        if ( absint( $source->get_id() ) === 0 ) {
1030            return $default_to;
1031        }
1032
1033        $post = get_post( $source->get_id() );
1034        if ( ! $post ) {
1035            return $default_to;
1036        }
1037
1038        return self::get_default_to_for_editor( $post );
1039    }
1040
1041    /**
1042     * Get the default recipient email address for the contact form based on post data.
1043     *
1044     * This is used when we load the post or page in the editor, and we don't have the post author ID directly.
1045     *
1046     * @param mixed|null $post Optional post data (object or array).
1047     *
1048     * @return string The default recipient email address.
1049     */
1050    public static function get_default_to_for_editor( $post = null ) {
1051        $default_to = get_option( 'admin_email' );
1052
1053        if ( empty( $post ) ) {
1054            return $default_to;
1055        }
1056
1057        $post_author_id = self::get_post_property( $post, 'post_author' );
1058        $post_id        = self::get_post_property( $post, 'ID' );
1059        $post_author    = get_user( $post_author_id );
1060
1061        // Check that the user has edit permissions for this blog and has an email address
1062        if ( empty( $post_author ) || empty( $post_author->user_email ) ) {
1063            return $default_to;
1064        }
1065
1066        // Check that the user is still a member of the blog.
1067        if ( ! is_user_member_of_blog( $post_author_id ) ) {
1068            return $default_to;
1069        }
1070
1071        // Check that the author can still edit the post or page.
1072        if ( user_can( $post_author_id, 'edit_post', $post_id ) ) {
1073            return $post_author->user_email;
1074        }
1075
1076        return $default_to;
1077    }
1078
1079    /**
1080     * Safely get a property from post data (object or array).
1081     *
1082     * @param mixed  $post_data Post data (object or array).
1083     * @param string $property  Property name to get.
1084     *
1085     * @return mixed|null The property value or null if not found.
1086     */
1087    public static function get_post_property( $post_data, $property ) {
1088        if ( ! $post_data ) {
1089            return null;
1090        }
1091
1092        if ( is_object( $post_data ) && isset( $post_data->$property ) ) {
1093            return $post_data->$property;
1094        } elseif ( is_array( $post_data ) && isset( $post_data[ $property ] ) ) {
1095            return $post_data[ $property ];
1096        }
1097
1098        return null;
1099    }
1100
1101    /**
1102     * Get the default subject for the contact form.
1103     *
1104     * @param array $attributes The attributes of the contact form.
1105     * @param mixed $post_data Optional post data (object or array).
1106     *
1107     * @return string The default subject for the contact form.
1108     */
1109    public static function get_default_subject( $attributes, $post_data = null ) {
1110        global $post;
1111        // Get the default subject for the contact form.
1112        $default_subject = '[' . get_option( 'blogname' ) . ']';
1113
1114        // Get post title safely
1115        $post_title = self::get_post_property( $post_data, 'post_title' );
1116
1117        if ( ! $post_title && $post ) {
1118            $post_title = self::get_post_property( $post, 'post_title' );
1119        }
1120
1121        if ( $post_title ) {
1122            $default_subject = sprintf(
1123                // translators: the blog name and post title.
1124                _x( '%1$s %2$s', '%1$s = blog name, %2$s = post title', 'jetpack-forms' ),
1125                $default_subject,
1126                Contact_Form_Plugin::strip_tags( $post_title )
1127            );
1128        }
1129
1130        if ( ! empty( $attributes['widget'] ) && $attributes['widget'] ) {
1131            // translators: '%1$s the blog name
1132            $default_subject = sprintf( _x( '%1$s Sidebar', '%1$s = blog name', 'jetpack-forms' ), $default_subject );
1133        }
1134
1135        return $default_subject;
1136    }
1137
1138    /**
1139     * Store shortcode content for recall later
1140     *  - used to receate shortcode when user uses do_shortcode
1141     *
1142     * @deprecated 5.0.0
1143     */
1144    public static function store_shortcode() {
1145        _deprecated_function( __METHOD__, '5.0.0', 'Contact_Form_Plugin::store_shortcode()' );
1146    }
1147
1148    /**
1149     * Toggle for printing the grunion.css stylesheet
1150     *
1151     * @param bool $style - the CSS style.
1152     *
1153     * @return bool
1154     */
1155    public static function style( $style ) {
1156        $previous_style = self::$style;
1157        self::$style    = (bool) $style;
1158        return $previous_style;
1159    }
1160
1161    /**
1162     * Turn on printing of grunion.css stylesheet
1163     *
1164     * @see ::style()
1165     *
1166     * @return bool
1167     */
1168    public static function style_on() {
1169        return self::style( true );
1170    }
1171    /**
1172     * Adds a quick link to the admin bar for the contact form entries.
1173     *
1174     * @param \WP_Admin_Bar $admin_bar The admin bar object.
1175     */
1176    public static function add_quick_link_to_admin_bar( \WP_Admin_Bar $admin_bar ) {
1177
1178        if ( ! current_user_can( 'edit_pages' ) ) {
1179            return;
1180        }
1181
1182        $url = Forms_Dashboard::get_forms_admin_url();
1183
1184        $icon = '<svg class="ab-icon" style="top: 2px; width: 20px; height: 20px; fill: currentColor;" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg"><path d="m13 7.5 h 5 v 1.5 h -5 v -1.5z"/><path d="m13 15 h 5 v 1.5 h -5 v -1.5z"/><path d="m19.01,3H4.99c-1.1,0-1.99.89-1.99,1.99v14.02c0,1.1.89,1.99,1.99,1.99h14.02c1.1,0,1.99-.89,1.99-1.99V4.99c0-1.1-.89-1.99-1.99-1.99Zm.49,15.99c0,.28-.23.51-.51.51H5.01c-.28,0-.51-.23-.51-.51V5.01c0-.28.23-.51.51-.51h13.98c.28,0,.51.23.51.51v13.98Z"/><path d="m9.46,13h-1.92c-.85,0-1.54.69-1.54,1.54v1.92c0,.85.69,1.54,1.54,1.54h1.92c.85,0,1.54-.69,1.54-1.54v-1.92c0-.85-.69-1.54-1.54-1.54Zm.04,3.5h-2v-2h2v2Z"/><path d="m9.46,6h-1.92c-.85,0-1.54.69-1.54,1.54v1.92c0,.85.69,1.54,1.54,1.54h1.92c.85,0,1.54-.69,1.54-1.54v-1.92c0-.85-.69-1.54-1.54-1.54Zm.04,3.5h-2v-2h2v2Z"/></svg>';
1185
1186        $admin_bar->add_menu(
1187            array(
1188                'id'     => 'jetpack-forms',
1189                'parent' => null,
1190                'group'  => null,
1191                'title'  => $icon . '<span class="ab-label">' . esc_html__( 'Form Responses', 'jetpack-forms' ) . '</span>',
1192                'href'   => $url,
1193                'meta'   => array(
1194                    'title' => esc_attr__( 'View form responses from this page', 'jetpack-forms' ),
1195                ),
1196            )
1197        );
1198    }
1199
1200    /**
1201     * The contact-form shortcode processor
1202     *
1203     * @param array       $attributes Key => Value pairs as parsed by shortcode_parse_atts().
1204     * @param string|null $content The shortcode's inner content: [contact-form]$content[/contact-form].
1205     * @param array       $context An array of context data for the form.
1206     *
1207     * @return string HTML for the concat form.
1208     */
1209    public static function parse( $attributes, $content, $context = array() ) {
1210        global $post, $page, $multipage; // $page is used in the contact-form submission redirect
1211        if ( Settings::is_syncing() ) {
1212            return '';
1213        }
1214
1215        // Handle ref attribute - load form from jetpack_form post
1216        if ( is_array( $attributes ) && isset( $attributes['ref'] ) ) {
1217            $ref_id = absint( $attributes['ref'] );
1218            if ( $ref_id > 0 ) {
1219                return self::render_synced_form( $ref_id );
1220            } else {
1221                return '';
1222            }
1223        }
1224
1225        if ( isset( $GLOBALS['grunion_block_template_part_id'] ) ) {
1226            self::style_on();
1227            if ( is_array( $attributes ) ) {
1228                $attributes['block_template_part'] = $GLOBALS['grunion_block_template_part_id'];
1229            }
1230        }
1231
1232        if ( is_singular() ) {
1233            add_action( 'admin_bar_menu', array( __CLASS__, 'add_quick_link_to_admin_bar' ), 100 ); // We use priority 100 so that the link that is added gets added after the "Edit Page" link.
1234        }
1235        $plugin               = Contact_Form_Plugin::init();
1236        $attributes['widget'] = $plugin->get_current_widget_context();
1237        // Create a new Contact_Form object (this class)
1238        if ( self::$ref_id ) {
1239            $attributes['ref'] = self::$ref_id;
1240        }
1241
1242        $form = new Contact_Form( $attributes, $content );
1243        Contact_Form_Plugin::reset_step();
1244
1245        $id = $form->get_attribute( 'id' );
1246
1247        if ( ! $id ) { // something terrible has happened
1248            return '[contact-form]';
1249        }
1250
1251        if ( is_feed() ) {
1252            return '[contact-form]';
1253        }
1254
1255        self::$last = $form;
1256
1257        // Enqueue the grunion.css stylesheet if self::$style allows it
1258        if ( self::$style && ( empty( $_REQUEST['action'] ) || $_REQUEST['action'] !== 'grunion_shortcode_to_json' ) ) {
1259            // Enqueue the style here instead of printing it, because if some other plugin has run the_post()+rewind_posts(),
1260            // (like VideoPress does), the style tag gets "printed" the first time and discarded, leaving the contact form unstyled.
1261            // when WordPress does the real loop.
1262            wp_enqueue_style( 'grunion.css' );
1263            wp_enqueue_script( 'accessible-form' );
1264        }
1265
1266        $version = \JETPACK__VERSION;
1267
1268        // Extra cache busting strategy for view.js, seems they are left out of cache clearing on deploys
1269        $asset_file = plugin_dir_path( __FILE__ ) . 'dist/modules/form/view.asset.php';
1270        $asset      = file_exists( $asset_file ) ? require $asset_file : null;
1271
1272        if ( $asset && isset( $asset['version'] ) ) {
1273            $version = $asset['version'];
1274        }
1275
1276        $config = array(
1277            'error_types'    => array(
1278                'is_required'        => __( 'This field is required.', 'jetpack-forms' ),
1279                'invalid_form_empty' => __( 'The form you are trying to submit is empty.', 'jetpack-forms' ),
1280                'invalid_form'       => __( 'Please fill out the form correctly.', 'jetpack-forms' ),
1281                'network_error'      => __( 'Connection issue while submitting the form. Check that you are connected to the Internet and try again.', 'jetpack-forms' ),
1282            ),
1283            'admin_ajax_url' => admin_url( 'admin-ajax.php' ),
1284        );
1285        wp_interactivity_config( 'jetpack/form', $config );
1286        \wp_enqueue_script_module(
1287            'jp-forms-view',
1288            plugins_url( 'dist/modules/form/view.js', dirname( __DIR__ ) ),
1289            array( '@wordpress/interactivity' ),
1290            $version
1291        );
1292
1293        $is_single_input_form = is_array( $form->fields ) && count( $form->fields ) === 1;
1294        $is_flex_layout       = isset( $attributes['layout']['type'] ) && $attributes['layout']['type'] === 'flex';
1295        $is_nowrap_layout     = isset( $attributes['layout']['flexWrap'] ) && $attributes['layout']['flexWrap'] === 'nowrap';
1296        $is_forced_horizontal = $is_flex_layout && $is_nowrap_layout
1297            && ( ! isset( $attributes['layout']['orientation'] ) || $attributes['layout']['orientation'] === 'horizontal' );
1298
1299        $extra_container_classes = array();
1300        if ( $is_forced_horizontal ) {
1301            $extra_container_classes[] = 'is-forced-horizontal-form';
1302        }
1303        if ( $is_single_input_form ) {
1304            $extra_container_classes[] = 'is-single-input-form';
1305        }
1306        $container_classes_string = self::get_block_container_classes( $attributes, $extra_container_classes );
1307
1308        $is_reload_after_success = isset( $_GET['contact-form-id'] )
1309        && (int) $_GET['contact-form-id'] === (int) self::$last->get_attribute( 'id' )
1310        && isset( $_GET['contact-form-sent'] )
1311        && isset( $_GET['contact-form-hash'] )
1312        && is_string( $_GET['contact-form-hash'] )
1313        && hash_equals( $form->hash, wp_unslash( $_GET['contact-form-hash'] ) );
1314
1315        $feedback_id           = 0;
1316        $is_reload_nonce_valid = false;
1317
1318        if ( $is_reload_after_success ) {
1319            $feedback_id           = (int) $_GET['contact-form-sent'];
1320            $is_reload_nonce_valid = isset( $_GET['_wpnonce'] )
1321                && wp_verify_nonce( sanitize_key( wp_unslash( $_GET['_wpnonce'] ) ), "contact-form-sent-{$feedback_id}" );
1322        }
1323
1324        $max_steps = 0;
1325        if ( preg_match_all( '/data-wp-context=[\'"]?{"step":(\d+)}[\'"]?/', $content, $matches ) ) {
1326            if ( ! empty( $matches[1] ) ) {
1327                $max_steps = max( array_map( 'intval', $matches[1] ) );
1328            }
1329        }
1330
1331        $is_multistep = $max_steps > 0;
1332        $element_id   = 'jp-form-' . esc_attr( $form->hash );
1333
1334        // Initial data used to render the success message when the page is reloaded after a successful submission
1335        // Don't show the feedback details unless the nonce matches
1336        $submission_data = null;
1337
1338        if ( $is_reload_after_success && $is_reload_nonce_valid ) {
1339            $response = Feedback::get( (int) $_GET['contact-form-sent'] );
1340
1341            if ( $response ) {
1342                $submission_data = $response->get_compiled_fields( 'web', 'collection' );
1343            }
1344        }
1345
1346        $formatted_submission_data = $submission_data ? self::format_submission_data( $submission_data ) : array();
1347        $submission_success        = $form->is_response_without_reload_enabled && $is_reload_after_success;
1348        $has_custom_redirect       = $form->has_custom_redirect();
1349
1350        $default_context = array(
1351            'formId'                  => $id,
1352            'formHash'                => $form->hash,
1353            'showErrors'              => $form->has_errors(), // We toggle this to true when we want to show the user errors right away.
1354            'errors'                  => array(), // This should be a associative array.
1355            'fields'                  => array(),
1356            'isMultiStep'             => $is_multistep, // Whether the form is a multistep form.
1357            'useAjax'                 => $form->is_response_without_reload_enabled && ! $has_custom_redirect,
1358            'submissionData'          => $submission_data,
1359            'formattedSubmissionData' => $formatted_submission_data,
1360            'submissionSuccess'       => $submission_success,
1361            'submissionError'         => null,
1362            'elementId'               => $element_id,
1363            'isSingleInputForm'       => $is_single_input_form,
1364            'isForcedHorizontal'      => $is_forced_horizontal,
1365        );
1366
1367        if ( $is_multistep ) {
1368            $multistep_context = array(
1369                'currentStep' => isset( $_GET[ $id . '-step' ] ) ? absint( $_GET[ $id . '-step' ] ) : 1,
1370                'maxSteps'    => $max_steps,
1371                'direction'   => 'forward', // Default direction for animations
1372                'transition'  => $form->get_attribute( 'stepTransition' ) ? $form->get_attribute( 'stepTransition' ) : 'fade-slide', // Transition style for step animations
1373            );
1374
1375            if ( ! is_array( $context ) ) {
1376                $context = array();
1377            }
1378            $context = array_merge( $context, $multistep_context );
1379        }
1380
1381        $context = is_array( $context ) ? array_merge( $default_context, $context ) : $default_context;
1382
1383        $r  = '';
1384        $r .= "<div data-test='contact-form'
1385            id='contact-form-$id'
1386            class='{$container_classes_string}'
1387            data-wp-interactive='jetpack/form' " . wp_interactivity_data_wp_context( $context ) . "
1388            data-wp-watch--scroll-to-wrapper=\"callbacks.scrollToWrapper\"
1389        >\n";
1390
1391        if ( $form->is_response_without_reload_enabled ) {
1392            $r .= self::render_ajax_success_wrapper( $form, $submission_success, $formatted_submission_data );
1393        }
1394
1395        if ( $form->has_errors() ) {
1396            // There are errors.  Display them
1397            $r .= "<div class='form-error'>\n<h3>" . __( 'Error!', 'jetpack-forms' ) . "</h3>\n<ul class='form-errors'>\n";
1398            foreach ( $form->get_error_messages() as $message ) {
1399                $r .= "\t<li class='form-error-message'>" . esc_html( $message ) . "</li>\n";
1400            }
1401            $r .= "</ul>\n</div>\n\n";
1402        }
1403
1404        if ( $is_reload_after_success && $form->is_response_without_reload_enabled ) {
1405            $r .= '<noscript>';
1406            $r .= self::render_noscript_success_message( $is_reload_nonce_valid, $feedback_id, $form );
1407            $r .= '</noscript>';
1408        }
1409
1410        if ( $is_reload_after_success && ! $form->is_response_without_reload_enabled ) {
1411            // The contact form was submitted.  Show the success message/results.
1412            $r .= self::render_noscript_success_message( $is_reload_nonce_valid, $feedback_id, $form );
1413        } else {
1414            // Nothing special - show the normal contact form
1415            if ( $form->get_attribute( 'widget' )
1416                || $form->get_attribute( 'block_template' )
1417                || $form->get_attribute( 'block_template_part' ) ) {
1418                // Submit form to the current URL
1419                $url = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', 'action', '_wpnonce' ) );
1420            } else {
1421                // Submit form to the post permalink
1422                $url = get_permalink();
1423                if ( $multipage && $page ) {
1424                    $url = add_query_arg( 'page', $page, $url );
1425                }
1426            }
1427
1428            // For SSL/TLS page. See RFC 3986 Section 4.2
1429            $url = set_url_scheme( $url );
1430
1431            // May eventually want to send this to admin-post.php...
1432            /**
1433             * Filter the contact form action URL.
1434             *
1435             * @module contact-form
1436             *
1437             * @since 1.3.1
1438             *
1439             * @param string $contact_form_id Contact form post URL.
1440             * @param $post $GLOBALS['post'] Post global variable.
1441             * @param int $id Contact Form ID.
1442             */
1443            $url                     = apply_filters( 'grunion_contact_form_form_action', $url, $GLOBALS['post'], $id, $page );
1444            $has_submit_button_block = str_contains( $content, 'wp-block-jetpack-button' ) || str_contains( $content, 'wp-block-button' );
1445            $form_classes            = 'contact-form commentsblock jetpack-contact-form__form';
1446            if ( $submission_success ) {
1447                $form_classes .= ' submission-success';
1448            }
1449
1450            if ( isset( $attributes['layout'] ) ) {
1451                $form_classes .= ' has-jetpack-form-layout';
1452            } else {
1453                $form_classes .= ' has-no-jetpack-form-layout';
1454            }
1455
1456            $post_title           = $post->post_title ?? '';
1457            $form_accessible_name = ! empty( $attributes['formTitle'] ) ? $attributes['formTitle'] : $post_title;
1458            $form_aria_label      = isset( $form_accessible_name ) && ! empty( $form_accessible_name ) ? 'aria-label="' . esc_attr( $form_accessible_name ) . '"' : '';
1459
1460            $r .= "<form action='" . esc_url( $url ) . "'
1461                id='" . $element_id . "'
1462                method='post'
1463                class='" . esc_attr( $form_classes ) . "$form_aria_label
1464                data-wp-on--submit=\"actions.onFormSubmit\"
1465                data-wp-on--reset=\"actions.onFormReset\"
1466                data-wp-class--submission-success=\"context.submissionSuccess\"
1467                data-wp-class--is-first-step=\"state.isFirstStep\"
1468                data-wp-class--is-last-step=\"state.isLastStep\"
1469                data-wp-class--is-ajax-form=\"context.useAjax\"
1470                novalidate >\n";
1471
1472            if ( $is_multistep ) { // This makes the "enter" key work in multi-step forms as expected.
1473                $r .= '<input type="submit" style="display: none;" />';
1474            }
1475            $r .= "<input type='hidden' name='jetpack_contact_form_jwt' value='" . esc_attr( $form->get_jwt() ) . "' />\n";
1476            $r .= $form->body;
1477
1478            if ( $is_multistep ) {
1479                $r = preg_replace( '/<div class="wp-block-jetpack-form-step-navigation__wrapper/', self::render_error_wrapper() . ' <div class="wp-block-jetpack-form-step-navigation__wrapper', $r, 1 );
1480            } elseif ( $has_submit_button_block ) {
1481                $r = self::prepare_submit_button( $r );
1482                // Place the error wrapper before the FIRST button block only to avoid duplicates (e.g., navigation buttons in multistep forms).
1483                // Replace only the first occurrence of a wp-block-jetpack-button prepending it with the error wrapper.
1484                // Fallback with same strategy for new core button blocks.
1485                if ( $is_forced_horizontal || $is_single_input_form ) {
1486                    // When user forced a horizontal layout, place the error wrapper
1487                    // after the form body.
1488                    $r .= self::render_error_wrapper( 'is-horizontal' );
1489                } else {
1490                    // Place the error wrapper before the FIRST button block only to avoid duplicates (e.g., navigation buttons in multistep forms).
1491                    // Replace only the first occurrence.
1492                    $r = preg_replace( '/<div class="wp-block-jetpack-button/', self::render_error_wrapper() . ' <div class="wp-block-jetpack-button', $r, 1 );
1493                    if ( str_contains( $r, 'wp-block-button' ) ) {
1494                        $r = preg_replace( '/<div class="wp-block-button/', self::render_error_wrapper() . ' <div class="wp-block-button', $r, 1 );
1495                    }
1496                }
1497            }
1498
1499            // In new versions of the contact form block the button is an inner block
1500            // so the button does not need to be constructed server-side.
1501            if ( ! $has_submit_button_block ) {
1502                $r .= "\t<p class='contact-submit'>\n";
1503
1504                $gutenberg_submit_button_classes = '';
1505                if ( ! empty( $attributes['submitButtonClasses'] ) ) {
1506                    $gutenberg_submit_button_classes = ' ' . $attributes['submitButtonClasses'];
1507                }
1508
1509                /**
1510                 * Filter the contact form submit button class attribute.
1511                 *
1512                 * @module contact-form
1513                 *
1514                 * @since 6.6.0
1515                 *
1516                 * @param string $class Additional CSS classes for button attribute.
1517                 */
1518                $submit_button_class = apply_filters( 'jetpack_contact_form_submit_button_class', 'pushbutton-wide' . $gutenberg_submit_button_classes );
1519
1520                $submit_button_styles = '';
1521                if ( ! empty( $attributes['customBackgroundButtonColor'] ) ) {
1522                    $submit_button_styles .= 'background-color: ' . $attributes['customBackgroundButtonColor'] . '; ';
1523                }
1524                if ( ! empty( $attributes['customTextButtonColor'] ) ) {
1525                    $submit_button_styles .= 'color: ' . $attributes['customTextButtonColor'] . ';';
1526                }
1527                if ( ! empty( $attributes['submitButtonText'] ) ) {
1528                    $submit_button_text = $attributes['submitButtonText'];
1529                } else {
1530                    $submit_button_text = $form->get_attribute( 'submit_button_text' );
1531                }
1532
1533                $r .= self::render_error_wrapper();
1534                $r .= "\t\t<button type='submit' class='" . esc_attr( $submit_button_class ) . "'";
1535                if ( ! empty( $submit_button_styles ) ) {
1536                    $r .= " style='" . esc_attr( $submit_button_styles ) . "'";
1537                }
1538                $r .= '>';
1539                $r .= wp_kses(
1540                    $submit_button_text,
1541                    self::$allowed_html_tags_for_submit_button
1542                ) . '</button>';
1543            }
1544
1545            if ( is_user_logged_in() ) {
1546                $r .= "\t\t" . wp_nonce_field( 'contact-form_' . $id, '_wpnonce', true, false ) . "\n"; // nonce and referer
1547            }
1548
1549            if ( isset( $attributes['hasFormSettingsSet'] ) && $attributes['hasFormSettingsSet'] ) {
1550                $r .= "\t\t<input type='hidden' name='is_block' value='1' />\n";
1551            }
1552            $r .= "\t\t<input type='hidden' name='contact-form-id' value='$id' />\n";
1553            $r .= "\t\t<input type='hidden' name='action' value='grunion-contact-form' />\n";
1554            $r .= "\t\t<input type='hidden' name='contact-form-hash' value='" . esc_attr( $form->hash ) . "' />\n";
1555
1556            if ( ! $has_submit_button_block ) {
1557                $r .= "\t</p>\n";
1558            }
1559
1560            $r .= "</form>\n";
1561        }
1562
1563        $r .= '</div>';
1564
1565        /**
1566         * Filter the contact form, allowing plugins to modify the HTML.
1567         *
1568         * @module contact-form
1569         *
1570         * @since 10.2.0
1571         *
1572         * @param string $r The contact form HTML.
1573         */
1574        return apply_filters( 'jetpack_contact_form_html', $r );
1575    }
1576
1577    /**
1578     * Prepare the submit button for the contact form.
1579     * Add interactivity attributes to submit buttons identified by:
1580     * - Legacy: type="submit" attribute
1581     * - New: is-submit or form-button-submit class
1582     *
1583     * @param string $content - the content of the submit button.
1584     *
1585     * @return string - the prepared content of the submit button.
1586     */
1587    private static function prepare_submit_button( $content ) {
1588        if ( ! class_exists( \WP_HTML_Tag_Processor::class ) ) {
1589            return $content;
1590        }
1591
1592        $p = new \WP_HTML_Tag_Processor( $content );
1593        while ( $p->next_tag( 'button' ) ) {
1594            $is_submit_by_type  = 'submit' === $p->get_attribute( 'type' );
1595            $is_submit_by_class = $p->has_class( 'is-submit' ) || $p->has_class( 'form-button-submit' );
1596
1597            if ( $is_submit_by_type || $is_submit_by_class ) {
1598                self::add_submit_button_interactivity_attributes( $p );
1599            }
1600        }
1601
1602        return $p->get_updated_html();
1603    }
1604
1605    /**
1606     * Adds Interactivity API attributes to the current element in a WP_HTML_Tag_Processor.
1607     *
1608     * Sets data-wp-class, data-wp-bind--aria-disabled, and data-wp-bind--disabled
1609     * on the submit button so the Interactivity API can toggle visual feedback
1610     * (spinner class, disabled state) while the form is submitting.
1611     *
1612     * Called from both single-step forms (prepare_submit_button) and multi-step
1613     * forms (gutenblock_render_form_step_navigation) to keep the attribute list
1614     * in one place.
1615     *
1616     * @param \WP_HTML_Tag_Processor $processor Tag processor positioned on a <button> element.
1617     * @return void
1618     */
1619    public static function add_submit_button_interactivity_attributes( $processor ) {
1620        if ( ! $processor || ! is_a( $processor, \WP_HTML_Tag_Processor::class ) ) {
1621            return;
1622        }
1623        $processor->set_attribute( 'data-wp-class--is-submitting', 'state.isSubmitting' );
1624        $processor->set_attribute( 'data-wp-bind--aria-disabled', 'state.isAriaDisabled' );
1625        $processor->set_attribute( 'data-wp-bind--disabled', 'state.isAriaDisabled' );
1626    }
1627
1628    /**
1629     * Renders the success message for the contact form when js is disabled or not desired.
1630     *
1631     * @param bool         $is_reload_nonce_valid - whether the nonce is valid.
1632     * @param int          $feedback_id - the feedback ID.
1633     * @param Contact_Form $form - the contact form.
1634     *
1635     * @return string HTML string for the success message.
1636     */
1637    private static function render_noscript_success_message( $is_reload_nonce_valid, $feedback_id, $form ) {
1638        $back_url        = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', '_wpnonce', 'contact-form-hash' ) );
1639        $contact_form_id = sanitize_text_field( wp_unslash( $_GET['contact-form-id'] ?? '' ) ); // phpcs:ignore WordPress.Security.NonceVerification.Recommended
1640        $disable_go_back = $form->get_attribute( 'disableGoBack' );
1641
1642        $message = '';
1643
1644        $message .= '<style>
1645            .contact-form-ajax-submission {
1646                display: none;
1647            }
1648
1649            #contact-form-' . $contact_form_id . ' form.contact-form {
1650                display: none;
1651            }
1652        </style>';
1653
1654        $message        .= '<div class="contact-form-submission">';
1655        $success_message = '';
1656
1657        if ( ! $disable_go_back ) {
1658            $success_message = '<p class="go-back-message"> <a class="link" href="' . esc_url( $back_url ) . '">' . esc_html__( '← Back', 'jetpack-forms' ) . '</a> </p>';
1659        }
1660
1661        $success_message .= '<h4 id="contact-form-success-header">' . esc_html( $form->get_attribute( 'customThankyouHeading' ) ) . "</h4>\n\n";
1662
1663        // Don't show the feedback details unless the nonce matches
1664        if ( $is_reload_nonce_valid ) {
1665            $success_message .= self::success_message( $feedback_id, $form );
1666        }
1667
1668        /**
1669         * Filter the message returned after a successful contact form submission.
1670         *
1671         * @module contact-form
1672         *
1673         * @since 1.3.1
1674         *
1675         * @param string $message Success message.
1676         */
1677        $message .= apply_filters( 'grunion_contact_form_success_message', $success_message );
1678        $message .= '</div>';
1679
1680        return $message;
1681    }
1682
1683    /**
1684     * Helper function to format the submission data for the success message.
1685     *
1686     * @param array $data The submission data (in 'collection' format with type).
1687     *
1688     * @return array The formatted submission data.
1689     */
1690    private static function format_submission_data( $data ) {
1691        $formatted_submission_data = array();
1692
1693        foreach ( $data as $field_data ) {
1694            $url    = self::get_url( $field_data['value'] );
1695            $images = self::get_images( $field_data['value'] );
1696            $files  = self::get_files( $field_data['value'] );
1697            $rating = self::get_rating( $field_data['value'] );
1698            $type   = $field_data['type'] ?? 'text';
1699
1700            $formatted_submission_data[] = array(
1701                'label'          => Util::maybe_add_colon_to_label( $field_data['label'] ),
1702                'value'          => self::maybe_transform_value( $field_data['value'] ),
1703                'images'         => $images,
1704                'url'            => $url,
1705                'files'          => $files,
1706                'rating'         => $rating,
1707                'type'           => $type,
1708                'showPlainValue' => empty( $url ) && empty( $images ) && empty( $files ) && empty( $rating ),
1709            );
1710        }
1711
1712        return $formatted_submission_data;
1713    }
1714
1715    /**
1716     * Get the URL from a URL field value if present.
1717     *
1718     * @param mixed $value The field value.
1719     *
1720     * @return string|null The URL if this is a URL field, null otherwise.
1721     */
1722    private static function get_url( $value ) {
1723        if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'url' && ! empty( $value['url'] ) ) {
1724            $url = $value['url'];
1725
1726            // Prepend https:// if no protocol is specified.
1727            if ( ! preg_match( '#^https?://#i', $url ) ) {
1728                $url = 'https://' . $url;
1729            }
1730
1731            // Validate URL - only http and https protocols are allowed for safety.
1732            $url = esc_url( $url, array( 'http', 'https' ) );
1733            return ! empty( $url ) ? $url : null;
1734        }
1735        return null;
1736    }
1737
1738    /**
1739     * Get the rating data from a rating field value if present.
1740     *
1741     * @param mixed $value The field value.
1742     *
1743     * @return array|null The rating data if this is a rating field, null otherwise.
1744     */
1745    private static function get_rating( $value ) {
1746        if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'rating' ) {
1747            $rating     = isset( $value['rating'] ) ? (int) $value['rating'] : 0;
1748            $max_rating = isset( $value['maxRating'] ) ? (int) $value['maxRating'] : 5;
1749            $icon_style = $value['iconStyle'] ?? 'stars';
1750
1751            // Generate translated screen reader text.
1752            $icon_label = 'hearts' === $icon_style
1753                ? _n( 'heart', 'hearts', $max_rating, 'jetpack-forms' )
1754                : _n( 'star', 'stars', $max_rating, 'jetpack-forms' );
1755
1756            return array(
1757                'rating'           => $rating,
1758                'maxRating'        => $max_rating,
1759                'iconStyle'        => $icon_style,
1760                /* translators: 1: rating value, 2: maximum rating, 3: icon type (stars or hearts) */
1761                'screenReaderText' => sprintf( __( 'Rating: %1$d out of %2$d %3$s', 'jetpack-forms' ), $rating, $max_rating, $icon_label ),
1762            );
1763        }
1764        return null;
1765    }
1766
1767    /**
1768     * Get the SVG icon for a field type.
1769     *
1770     * @param string $field_type The field type.
1771     *
1772     * @return string The SVG icon HTML.
1773     */
1774    private static function get_field_type_icon( $field_type ) {
1775        // Reject field types that don't fit the expected 'field-{type}' naming
1776        // convention. Valid types are non-empty strings of lowercase letters,
1777        // digits, and hyphens starting with a letter.
1778        if ( ! is_string( $field_type ) || ! preg_match( '/^[a-z][a-z0-9-]*$/', $field_type ) ) {
1779            return '';
1780        }
1781
1782        // Map field types that don't follow the 'field-{type}' naming convention.
1783        static $type_exceptions = array(
1784            'phone'             => 'field-telephone',
1785            'telephone'         => 'field-telephone',
1786            'radio'             => 'field-single-choice',
1787            'checkbox-multiple' => 'field-multiple-choice',
1788        );
1789
1790        $block_dir = $type_exceptions[ $field_type ] ?? 'field-' . $field_type;
1791
1792        // Cache loaded SVG content to avoid re-reading files.
1793        static $icon_cache = array();
1794
1795        if ( ! isset( $icon_cache[ $block_dir ] ) ) {
1796            $svg_file = dirname( __DIR__ ) . '/blocks/' . $block_dir . '/icon.svg';
1797            $svg      = '';
1798
1799            if ( file_exists( $svg_file ) ) {
1800                $svg = file_get_contents( $svg_file ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents -- Reading local package file, not a remote URL.
1801            }
1802
1803            if ( $svg ) {
1804                $svg = trim( $svg );
1805
1806                $icon_cache[ $block_dir ] = $svg;
1807            } else {
1808                $icon_cache[ $block_dir ] = '';
1809            }
1810        }
1811
1812        return $icon_cache[ $block_dir ];
1813    }
1814
1815    /**
1816     * Helper function that display the error wrapper.
1817     *
1818     * @param string $classes - the class names to add to the error wrapper.
1819     * @return string HTML string for the error wrapper.
1820     */
1821    private static function render_error_wrapper( $classes = '' ) {
1822        $class_attr = $classes ? ' ' . esc_attr( $classes ) : '';
1823        $html       = '<div class="contact-form__error' . $class_attr . '" data-wp-class--show-errors="state.showFormErrors">';
1824        $html      .= '<span class="contact-form__warning-icon" aria-hidden="true"><i></i></span>';
1825        $html      .= '<span class="contact-form__error-message" tabindex="-1" data-wp-watch="callbacks.focusOnValidationError" data-wp-text="state.getFormErrorMessage"></span>';
1826        $html      .= '<ul aria-label="' . esc_attr__( 'Form errors', 'jetpack-forms' ) . '">
1827                <template data-wp-each="state.getErrorList" data-wp-key="context.item.id">
1828                    <li><a data-wp-bind--href="context.item.anchor" data-wp-on--click="actions.scrollIntoView" data-wp-text="context.item.label"></a></li>
1829                </template>
1830                </ul>';
1831        $html      .= '</div>';
1832
1833        $html .= '<div class="contact-form__error" data-wp-class--show-errors="state.showSubmissionError" data-wp-text="context.submissionError" tabindex="-1" data-wp-watch="callbacks.focusOnSubmissionError"></div>';
1834        return $html;
1835    }
1836
1837    /**
1838     * Renders the success wrapper after a form is submitted without reloading the page.
1839     *
1840     * @param Contact_Form $form - the contact form.
1841     * @param bool         $submission_success - whether the form has already been submitted.
1842     * @param array        $formatted_submission_data - the formatted submission data.
1843     *
1844     * @return string HTML string for the success wrapper.
1845     */
1846    private static function render_ajax_success_wrapper( $form, $submission_success = false, $formatted_submission_data = array() ) {
1847        $classes = 'contact-form-submission contact-form-ajax-submission';
1848
1849        if ( $submission_success ) {
1850            $classes .= ' submission-success';
1851        }
1852
1853        $back_url          = remove_query_arg( array( 'contact-form-id', 'contact-form-sent', '_wpnonce', 'contact-form-hash' ) );
1854        $disable_go_back   = $form->get_attribute( 'disableGoBack' );
1855        $disable_summary   = $form->get_disable_summary();
1856        $confirmation_type = $form->get_confirmation_type();
1857
1858        if ( $confirmation_type === 'redirect' ) {
1859            return '';
1860        }
1861
1862        $html = '<div class="' . esc_attr( $classes ) . '" data-wp-bind--aria-hidden="state.isSuccessMessageAriaHidden" data-wp-class--submission-success="context.submissionSuccess" id="contact-form-success-' . esc_attr( $form->hash ) . '" tabindex="-1" aria-labelledby="contact-form-success-header-' . esc_attr( $form->hash ) . '">';
1863
1864        if ( ! $disable_go_back ) {
1865            $html .= '<p class="go-back-message">';
1866            $html .= '<a class="link" role="button" tabindex="0" data-wp-on--click="actions.goBack" href="' . esc_url( $back_url ) . '">' . esc_html__( '← Back', 'jetpack-forms' ) . '</a>';
1867            $html .= '</p>';
1868        }
1869
1870        $html .=
1871            '<h4 data-wp-bind--aria-hidden="state.isSuccessMessageAriaHidden" id="contact-form-success-header-' . esc_attr( $form->hash ) . '">' . esc_html( $form->get_attribute( 'customThankyouHeading' ) ) .
1872            "</h4>\n\n";
1873
1874        if ( 'text' === $confirmation_type ) {
1875            $raw_message = $form->get_attribute( 'customThankyouMessage' );
1876
1877            if ( $raw_message !== '' ) {
1878                // Add more allowed HTML elements for file download links
1879                $allowed_html = array(
1880                    'br'         => array(),
1881                    'blockquote' => array( 'class' => array() ),
1882                    'p'          => array(),
1883                    'div'        => array(
1884                        'class' => array(),
1885                        'style' => array(),
1886                    ),
1887                    'span'       => array(
1888                        'class' => array(),
1889                        'style' => array(),
1890                    ),
1891                );
1892
1893                $message = wp_kses( $raw_message, $allowed_html );
1894                $message = '<div class="jetpack_forms_contact-form-custom-success-message">' . $message . '</div>';
1895
1896                $html .= $message;
1897            }
1898
1899            if ( ! $disable_summary ) {
1900                $html .= '<template data-wp-each--submission="context.formattedSubmissionData">
1901                    <div class="jetpack_forms_contact-form-success-summary">
1902                        <div class="field-name-wrapper">
1903                            <div class="field-type-icon" data-wp-watch="callbacks.watchFieldTypeIcon"></div>
1904                            <div class="field-name" data-wp-text="context.submission.label" data-wp-bind--hidden="!context.submission.label"></div>
1905                        </div>
1906                        <div class="field-value" data-wp-text="context.submission.value" data-wp-bind--hidden="!context.submission.showPlainValue"></div>
1907                        <a class="field-url" data-wp-bind--href="context.submission.url" data-wp-text="context.submission.value" data-wp-bind--hidden="!context.submission.url" target="_blank" rel="noopener noreferrer"></a>
1908                        <div class="field-rating" data-wp-bind--hidden="!context.submission.rating" data-wp-watch="callbacks.watchRatingIcons"></div>
1909                        <div class="field-images" data-wp-bind--hidden="!context.submission.images">
1910                            <template data-wp-each--image="context.submission.images">
1911                                <div class="field-image-option" data-wp-class--is-empty="!context.image.src">
1912                                    <figure class="field-image-option__image" data-wp-class--is-empty="!context.image.src">
1913                                        <img data-wp-bind--src="context.image.src" data-wp-bind--hidden="!context.image.src" />
1914                                        <img src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" data-wp-bind--hidden="context.image.src" />
1915                                    </figure>
1916                                    <div class="field-image-option__label-wrapper">
1917                                        <span class="field-image-option__label-code" data-wp-text="context.image.letterCode"></span>
1918                                        <span class="field-image-option__label" data-wp-text="context.image.label" data-wp-bind--hidden="!context.image.label"></span>
1919                                    </div>
1920                                </div>
1921                            </template>
1922                        </div>
1923                        <div class="field-files" data-wp-bind--hidden="!context.submission.files">
1924                            <template data-wp-each--file="context.submission.files">
1925                                <div class="field-file">
1926                                    <div class="field-file__thumbnail" data-wp-style--background-image="context.file.previewUrl" data-wp-style--mask-image="context.file.iconUrl" data-wp-bind--hidden="!context.file.hasPreview"></div>
1927                                    <svg class="field-file__icon" data-wp-bind--hidden="context.file.hasPreview" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true">
1928                                        <path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.89 22 5.99 22H18C19.1 22 20 21.1 20 20V8L14 2ZM18 20H6V4H13V9H18V20Z" fill="currentColor"/>
1929                                    </svg>
1930                                    <span class="field-file__name" data-wp-text="context.file.name"></span>
1931                                    <span class="field-file__size" data-wp-text="context.file.size"></span>
1932                                </div>
1933                            </template>
1934                        </div>
1935                    </div>
1936                </template>';
1937
1938                // For each entry in the submission data array, render a div with the label and value.
1939                // Structure must match the template above for proper hydration.
1940                foreach ( $formatted_submission_data as $submission ) {
1941                    $has_url        = ! empty( $submission['url'] );
1942                    $has_images     = ! empty( $submission['images'] );
1943                    $has_files      = ! empty( $submission['files'] );
1944                    $has_rating     = ! empty( $submission['rating'] );
1945                    $show_plain_val = ! $has_url && ! $has_images && ! $has_files && ! $has_rating;
1946                    $field_type     = $submission['type'] ?? 'text';
1947
1948                    $html .= '<div data-wp-each-child class="jetpack_forms_contact-form-success-summary">';
1949
1950                    // field-name-wrapper: contains icon and label.
1951                    $html .= '<div class="field-name-wrapper">';
1952                    // field-type-icon: rendered based on field type.
1953                    // The data-rendered-type attribute enables hydration optimization by allowing
1954                    // the JS callback to skip re-rendering when the icon is already correct.
1955                    $html .= '<div class="field-type-icon" data-wp-watch="callbacks.watchFieldTypeIcon" data-rendered-type="' . esc_attr( $field_type ) . '">' . self::get_field_type_icon( $field_type ) . '</div>';
1956                    // field-name: always present.
1957                    $html .= '<div class="field-name" data-wp-text="context.submission.label" data-wp-bind--hidden="!context.submission.label">' . esc_html( $submission['label'] ) . '</div>';
1958                    $html .= '</div>'; // Close field-name-wrapper.
1959
1960                    // field-value: always present, hidden when URL, images, or files exist.
1961                    $html .= '<div class="field-value" data-wp-text="context.submission.value" data-wp-bind--hidden="!context.submission.showPlainValue"';
1962                    $html .= $show_plain_val ? '' : ' hidden';
1963                    $html .= '>' . ( $show_plain_val ? esc_html( $submission['value'] ) : '' ) . '</div>';
1964
1965                    // field-url: always present, hidden when no URL.
1966                    $html .= '<a class="field-url" data-wp-bind--href="context.submission.url" data-wp-text="context.submission.value" data-wp-bind--hidden="!context.submission.url" target="_blank" rel="noopener noreferrer"';
1967                    $html .= $has_url ? ' href="' . esc_attr( $submission['url'] ) . '"' : ' hidden';
1968                    $html .= '>' . ( $has_url ? esc_html( $submission['value'] ) : '' ) . '</a>';
1969
1970                    // Field rating - only visible when rating is present. JS renders the SVG icons.
1971                    $html .= '<div class="field-rating" data-wp-bind--hidden="!context.submission.rating" data-wp-watch="callbacks.watchRatingIcons"';
1972                    $html .= $has_rating ? ' data-rating="' . esc_attr( wp_json_encode( $submission['rating'], JSON_UNESCAPED_SLASHES ) ) . '">' : ' hidden>';
1973                    $html .= '</div>';
1974
1975                    // field-images: always present, hidden when no images.
1976                    $html .= '<div class="field-images" data-wp-bind--hidden="!context.submission.images"';
1977                    $html .= $has_images ? '' : ' hidden';
1978                    $html .= '>';
1979
1980                    if ( $has_images ) {
1981                        foreach ( $submission['images'] as $image ) {
1982                            $image_src         = $image['src'] ?? '';
1983                            $image_letter_code = $image['letterCode'] ?? '';
1984                            $image_label       = $image['label'] ?? '';
1985
1986                            $html .= '<div data-wp-each-child class="field-image-option ' . ( empty( $image_src ) ? 'is-empty' : '' ) . '" data-wp-class--is-empty="!context.image.src">';
1987                            $html .= '<figure class="field-image-option__image ' . ( empty( $image_src ) ? 'is-empty' : '' ) . '" data-wp-class--is-empty="!context.image.src">';
1988                            $html .= '<img data-wp-bind--src="context.image.src" src="' . esc_attr( $image_src ) . '" data-wp-bind--hidden="!context.image.src"' . ( empty( $image_src ) ? ' hidden' : '' ) . '/>';
1989                            $html .= '<img src="data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs=" data-wp-bind--hidden="context.image.src"' . ( empty( $image_src ) ? '' : ' hidden' ) . '/>';
1990                            $html .= '</figure>';
1991                            $html .= '<div class="field-image-option__label-wrapper">';
1992                            $html .= '<span class="field-image-option__label-code" data-wp-text="context.image.letterCode">' . esc_html( $image_letter_code ) . '</span>';
1993                            $html .= '<span class="field-image-option__label" data-wp-text="context.image.label" data-wp-bind--hidden="!context.image.label"' . ( empty( $image_label ) ? ' hidden' : '' ) . '>' . esc_html( $image_label ) . '</span>';
1994                            $html .= '</div></div>';
1995                        }
1996                    } else {
1997                        // Empty template for hydration when no images.
1998                        $html .= '<template data-wp-each--image="context.submission.images"></template>';
1999                    }
2000
2001                    $html .= '</div>'; // Close field-images.
2002
2003                    // field-files: always present, hidden when no files.
2004                    $html .= '<div class="field-files" data-wp-bind--hidden="!context.submission.files"';
2005                    $html .= $has_files ? '' : ' hidden';
2006                    $html .= '>';
2007
2008                    if ( $has_files ) {
2009                        foreach ( $submission['files'] as $file ) {
2010                            $file_name   = $file['name'] ?? '';
2011                            $file_size   = $file['size'] ?? '';
2012                            $has_preview = $file['hasPreview'] ?? false;
2013
2014                            $html .= '<div data-wp-each-child class="field-file">';
2015                            // Thumbnail for AJAX submissions (has preview data)
2016                            $html .= '<div class="field-file__thumbnail" data-wp-style--background-image="context.file.previewUrl" data-wp-style--mask-image="context.file.iconUrl" data-wp-bind--hidden="!context.file.hasPreview"';
2017                            $html .= $has_preview ? '' : ' hidden';
2018                            $html .= '></div>';
2019                            // SVG fallback for non-AJAX submissions
2020                            $html .= '<svg class="field-file__icon" data-wp-bind--hidden="context.file.hasPreview" width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true"';
2021                            $html .= $has_preview ? ' hidden' : '';
2022                            $html .= '>';
2023                            $html .= '<path d="M14 2H6C4.9 2 4 2.9 4 4V20C4 21.1 4.89 22 5.99 22H18C19.1 22 20 21.1 20 20V8L14 2ZM18 20H6V4H13V9H18V20Z" fill="currentColor"/>';
2024                            $html .= '</svg>';
2025                            $html .= '<span class="field-file__name" data-wp-text="context.file.name">' . esc_html( $file_name ) . '</span>';
2026                            $html .= '<span class="field-file__size" data-wp-text="context.file.size">' . esc_html( $file_size ) . '</span>';
2027                            $html .= '</div>';
2028                        }
2029                    } else {
2030                        // Empty template for hydration when no files.
2031                        $html .= '<template data-wp-each--file="context.submission.files"></template>';
2032                    }
2033
2034                    $html .= '</div></div>'; // Close field-files and summary.
2035                }
2036            }
2037        }
2038
2039        $html .= '</div>';
2040        return $html;
2041    }
2042
2043    /**
2044     * Returns a success message to be returned if the form is sent via AJAX.
2045     *
2046     * @param int          $feedback_id - the feedback ID.
2047     * @param Contact_Form $form - the contact form.
2048     *
2049     * @return string $message
2050     */
2051    public static function success_message( $feedback_id, $form ) {
2052        $message           = '';
2053        $disable_summary   = $form->get_disable_summary();
2054        $confirmation_type = $form->get_confirmation_type();
2055
2056        if ( 'text' === $confirmation_type ) {
2057            $raw_message = $form->get_attribute( 'customThankyouMessage' );
2058
2059            if ( $raw_message !== '' ) {
2060                // Add more allowed HTML elements for file download links
2061                $allowed_html = array(
2062                    'br'         => array(),
2063                    'blockquote' => array( 'class' => array() ),
2064                    'p'          => array(),
2065                    'div'        => array(
2066                        'class' => array(),
2067                        'style' => array(),
2068                    ),
2069                    'span'       => array(
2070                        'class' => array(),
2071                        'style' => array(),
2072                    ),
2073                );
2074
2075                $message = wp_kses( $raw_message, $allowed_html );
2076                $message = '<div class="jetpack_forms_contact-form-custom-success-message">' . $message . '</div>';
2077            }
2078
2079            if ( ! $disable_summary ) {
2080                $compiled_form = self::get_compiled_form( $feedback_id );
2081
2082                $message .= '<div class="jetpack_forms_contact-form-success-summary"><p>' . implode( '</p><p>', $compiled_form ) . '</p></div>';
2083            }
2084        }
2085
2086        return $message;
2087    }
2088
2089    /**
2090     * Returns a compiled form with labels and values in a form of  an array
2091     * of lines.
2092     *
2093     * @param int          $feedback_id - the feedback ID.
2094     * @param Contact_Form $form - the form. This parameter is deprecated and will be removed in the next version.
2095     *
2096     * @return array $lines
2097     */
2098    public static function get_compiled_form( $feedback_id, $form = null ) {
2099
2100        if ( $form ) {
2101            _deprecated_argument( __METHOD__, '5.1.0', '$form is deprecated' );
2102        }
2103        $compiled_form = self::get_raw_compiled_form_data( $feedback_id );
2104
2105        foreach ( $compiled_form as $field_index => $data ) {
2106            $safe_display_value = self::escape_and_sanitize_field_value( $data['value'] );
2107
2108            if ( '' === $safe_display_value ) {
2109                $safe_display_value = '-';
2110            }
2111
2112            if ( ! empty( $data['label'] ) ) {
2113                $safe_display_label            = self::escape_and_sanitize_field_label( $data['label'] );
2114                $compiled_form[ $field_index ] = sprintf(
2115                    '<div class="field-name">%1$s</div> <div class="field-value">%2$s</div>',
2116                    Util::maybe_add_colon_to_label( $safe_display_label ),
2117                    $safe_display_value
2118                );
2119            } else {
2120                // If there is no label, only output the field value, wrapped in its div.
2121                $compiled_form[ $field_index ] = sprintf(
2122                    '<div class="field-value">%s</div>',
2123                    $safe_display_value
2124                );
2125            }
2126        }
2127
2128        return $compiled_form;
2129    }
2130
2131    /**
2132     * Returns the JSON data for the form submission.
2133     *
2134     * @param int          $feedback_id - the feedback ID.
2135     * @param Contact_Form $form - the form. This parameter is deprecated and will be removed in the next version.
2136     *
2137     * @deprecated 5.1.0
2138     *
2139     * @return array $json_data
2140     */
2141    public static function get_json_data( $feedback_id, $form = null ) {
2142        _deprecated_function( __METHOD__, '5.1.0', 'Feedback::get( $feedback_id )->get_compiled_fields(\'ajax\', \'label|value\' )' );
2143
2144        if ( $form ) {
2145            _deprecated_argument( __METHOD__, '5.1.0', '$form is deprecated' );
2146        }
2147
2148        $response = Feedback::get( $feedback_id );
2149        if ( ! $response ) {
2150            return array();
2151        }
2152
2153        return $response->get_compiled_fields( 'ajax', 'label|value' );
2154    }
2155
2156    /**
2157     * Retrieves raw compiled form data.
2158     *
2159     * @param int          $feedback_id - the feedback ID.
2160     * @param Contact_Form $form - the form. This parameter is deprecated and will be removed in the next version.
2161     *
2162     * @return array $raw_data Associative array where keys are field_index and values are arrays with 'label' and 'value'.
2163     */
2164    private static function get_raw_compiled_form_data( $feedback_id, $form = null ) {
2165
2166        if ( $form ) {
2167            _deprecated_argument( __METHOD__, '5.1.0', '$form is deprecated' );
2168        }
2169
2170        $response = Feedback::get( $feedback_id );
2171        if ( $response instanceof Feedback ) {
2172            // If the response is an instance of Feedback, we can use its method to get compiled fields.
2173            return $response->get_compiled_fields( 'web', 'all' );
2174        }
2175
2176        return array();
2177    }
2178
2179    /**
2180     * Returns a compiled form with labels and values formatted for the email response
2181     * in a form of an array of lines.
2182     *
2183     * @param int          $feedback_id - the feedback ID.
2184     * @param Contact_Form $form - the form.
2185     *
2186     * @return array $lines
2187     */
2188    public static function get_compiled_form_for_email( $feedback_id, $form ) {
2189        return Feedback_Email_Renderer::get_compiled_form_for_email( $feedback_id, $form );
2190    }
2191
2192    /**
2193     * Escape and sanitize a field value.
2194     *
2195     * @param mixed $value - the value to sanitize.
2196     *
2197     * TODO: there's a mix of functionalities in this method. Unsure if it's fixable.
2198     * @return string
2199     */
2200    public static function escape_and_sanitize_field_value( $value ) {
2201        if ( empty( $value ) ) {
2202            return '';
2203        }
2204
2205        // Handle file upload field (new structure with field_id and files array).
2206        if ( self::is_file_upload_field( $value ) ) {
2207            $files = $value['files'];
2208            if ( empty( $files ) ) {
2209                return '';
2210            }
2211
2212            $file_links = array();
2213            foreach ( $files as $file ) {
2214                if ( ! empty( $file['file_id'] ) ) {
2215                    $file_name = $file['name'] ?? __( 'Attached file', 'jetpack-forms' );
2216                    $file_size = isset( $file['size'] ) ? size_format( $file['size'] ) : '';
2217
2218                    $html = esc_html( $file_name );
2219                    if ( ! empty( $file_size ) ) {
2220                        $html .= sprintf( ' <span class="jetpack-forms-file-size">(%s)</span>', esc_html( $file_size ) );
2221                    }
2222
2223                    $file_links[] = $html;
2224                }
2225            }
2226
2227            return implode( '<br>', $file_links );
2228        }
2229
2230        // Handle rating field - return displayValue (e.g., "3/5") as text fallback.
2231        if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'rating' ) {
2232            return isset( $value['displayValue'] ) ? esc_html( $value['displayValue'] ) : '';
2233        }
2234
2235        // Handle URL field - return displayValue or url.
2236        if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'url' ) {
2237            return isset( $value['displayValue'] ) ? esc_html( $value['displayValue'] ) : ( isset( $value['url'] ) ? esc_html( $value['url'] ) : '' );
2238        }
2239
2240        if ( is_array( $value ) ) {
2241            return implode( ', ', array_map( array( __CLASS__, 'escape_and_sanitize_field_value' ), $value ) );
2242        }
2243
2244        $value = str_replace( array( '[', ']' ), array( '&#91;', '&#93;' ), $value );
2245        return nl2br( wp_kses( $value, array() ) );
2246    }
2247
2248    /**
2249     * Only strip out empty string values and keep all the other values as they are.
2250     *
2251     * @param string $single_value - the single value.
2252     *
2253     * @return bool
2254     */
2255    public static function remove_empty( $single_value ) {
2256        return ( $single_value !== '' );
2257    }
2258
2259    /**
2260     * Get file upload fields
2261     *
2262     * @param int $post_id The feedback post ID.
2263     * @return array Array of file attachments or empty array.
2264     */
2265    public static function get_file_upload_fields( $post_id ) {
2266        $content_fields     = Contact_Form_Plugin::parse_fields_from_content( $post_id );
2267        $file_upload_fields = array();
2268        if ( isset( $content_fields['_feedback_all_fields'] ) ) {
2269            foreach ( $content_fields['_feedback_all_fields'] as $field_value ) {
2270                if ( self::is_file_upload_field( $field_value ) ) {
2271                    $file_upload_fields[] = $field_value;
2272                }
2273            }
2274        }
2275
2276        return $file_upload_fields;
2277    }
2278
2279    /**
2280     * Delete files
2281     *
2282     * @param int $post_id The post ID being deleted.
2283     * @return void
2284     */
2285    public static function delete_feedback_files( $post_id ) {
2286        if ( get_post_type( $post_id ) !== 'feedback' ) {
2287            return;
2288        }
2289        // $file_upload_fields = self::get_file_upload_fields( $post_id );
2290        // TODO: Implement delete_feedback_files() method.
2291    }
2292
2293    /**
2294     * Escape a shortcode value.
2295     *
2296     * Shortcode attribute values have a number of unfortunate restrictions, which fortunately we
2297     * can get around by adding some extra HTML encoding.
2298     *
2299     * The output HTML will have a few extra escapes, but that makes no functional difference.
2300     *
2301     * @since 9.1.0
2302     * @param string|array $val Value to escape.
2303     * @return string
2304     */
2305    public static function esc_shortcode_val( $val ) {
2306        // Sometimes we provide attributes in the form of a collection, hence making the value an array.
2307        // The above case triggers a warning about array to string conversion on formatting.php:1096.
2308        // This chunk will try to get the value from the usual label|value structure. Otherwise, it will try
2309        // recursively to get the first value from the array.
2310        if ( is_array( $val ) ) {
2311            if ( isset( $val['value'] ) ) {
2312                $val = $val['value'];
2313            } else {
2314                return self::esc_shortcode_val( array_shift( $val ) );
2315            }
2316        }
2317
2318        return strtr(
2319            esc_html( $val ),
2320            array(
2321                // Brackets in attribute values break the shortcode parser.
2322                '['  => '&#091;',
2323                ']'  => '&#093;',
2324                // Shortcode parser screws up backslashes too, thanks to calls to `stripcslashes`.
2325                '\\' => '&#092;',
2326                // The existing code here represents arrays as comma-separated strings.
2327                // Rather than trying to change representations now, just escape the commas in values.
2328                ','  => '&#044;',
2329            )
2330        );
2331    }
2332
2333    /**
2334     * The contact-field shortcode processor.
2335     * We use an object method here instead of a static Contact_Form_Field class method to parse contact-field shortcodes so that we can tie them to the contact-form object.
2336     *
2337     * @param array         $attributes Key => Value pairs as parsed by shortcode_parse_atts().
2338     * @param string|null   $content The shortcode's inner content: [contact-field]$content[/contact-field].
2339     * @param WP_Block|null $block The field block object.
2340     * @return string HTML for the contact form field
2341     */
2342    public static function parse_contact_field( $attributes, $content, $block = null ) {
2343        if ( $block ) {
2344            $type = null;
2345        }
2346
2347        // Don't try to parse contact form fields if not inside a contact form (????)
2348        if ( ! Contact_Form_Plugin::$using_contact_form_field ) {
2349            $type = $attributes['type'] ?? null;
2350
2351            if ( $type === 'checkbox-multiple' || $type === 'radio' ) {
2352                preg_match_all( '/' . get_shortcode_regex() . '/s', $content, $matches );
2353
2354                if ( ! empty( $matches[0] ) ) {
2355                    $options = array();
2356                    foreach ( $matches[0] as $shortcode ) {
2357                        $attr = shortcode_parse_atts( $shortcode );
2358                        if ( ! empty( $attr['label'] ) ) {
2359                            $options[] = $attr['label'];
2360                        }
2361                    }
2362
2363                    $attributes['options'] = $options;
2364                }
2365            }
2366
2367            if ( ! isset( $attributes['label'] ) ) {
2368                $attributes['label'] = self::get_default_label_from_type( $type );
2369            }
2370
2371            $att_strs = array();
2372            foreach ( $attributes as $att => $val ) {
2373                if ( is_numeric( $att ) ) { // Is a valueless attribute
2374                    $att_strs[] = self::esc_shortcode_val( $val );
2375                } elseif ( isset( $val ) ) { // A regular attr - value pair
2376                    if ( ( $att === 'options' || $att === 'values' ) && is_string( $val ) ) { // remove any empty strings
2377                        $val = explode( ',', $val );
2378                    }
2379                    if ( is_array( $val ) ) {
2380                        $val        = array_filter( $val, array( __CLASS__, 'remove_empty' ) ); // removes any empty strings
2381                        $att_strs[] = esc_html( $att ) . '="' . implode( ',', array_map( array( __CLASS__, 'esc_shortcode_val' ), $val ) ) . '"';
2382                    } elseif ( is_bool( $val ) ) {
2383                        $att_strs[] = esc_html( $att ) . '="' . ( $val ? '1' : '' ) . '"';
2384                    } else {
2385                        // Allow CSS in known style attributes byut sanitize with safecss_filter_attr.
2386                        $allowed_style_keys = array( 'labelstyles', 'inputstyles', 'optionstyles', 'optionsstyles', 'stylevariationstyles' );
2387                        if ( in_array( $att, $allowed_style_keys, true ) ) {
2388                            $sanitized  = safecss_filter_attr( (string) $val );
2389                            $att_strs[] = esc_attr( $att ) . '="' . esc_html( $sanitized ) . '"';
2390                        } else {
2391                            $att_strs[] = esc_attr( $att ) . '="' . self::esc_shortcode_val( $val ) . '"';
2392                        }
2393                    }
2394                }
2395            }
2396
2397            $shortcode_type = 'contact-field';
2398            if ( $type === 'field-option' ) {
2399                $shortcode_type = 'contact-field-option';
2400            }
2401
2402            $html            = '[' . $shortcode_type . ' ' . implode( ' ', $att_strs );
2403            $trimmed_content = isset( $content ) ? trim( $content ) : '';
2404
2405            if ( ! empty( $trimmed_content ) ) { // If there is content, let's add a closing tag
2406                $html .= ']' . esc_html( $trimmed_content ) . '[/contact-field]';
2407            } else { // Otherwise let's add a closing slash in the first tag
2408                $html .= '/]';
2409            }
2410
2411            return $html;
2412        }
2413
2414        // What does this actually means? What is the case where this is used?
2415        $form = self::$current_form;
2416
2417        $field = new Contact_Form_Field( $attributes, $content, $form );
2418
2419        $field_id = $field->get_attribute( 'id' );
2420        if ( $field_id ) {
2421            $form->fields[ $field_id ] = $field;
2422        } else {
2423            $form->fields[] = $field;
2424        }
2425
2426        if ( // phpcs:disable WordPress.Security.NonceVerification.Missing
2427            ! isset( $_POST['jetpack_contact_form_jwt'] )
2428            &&
2429            isset( $_POST['action'] ) && 'grunion-contact-form' === $_POST['action']
2430            &&
2431            isset( $_POST['contact-form-id'] ) && (string) $form->get_attribute( 'id' ) === $_POST['contact-form-id']
2432            &&
2433            isset( $_POST['contact-form-hash'] ) && is_string( $_POST['contact-form-hash'] ) && hash_equals( $form->hash, wp_unslash( $_POST['contact-form-hash'] ) )
2434        ) { // phpcs:enable
2435            // If we're processing a POST submission for this contact form, validate the field value so we can show errors as necessary.
2436            $field->validate();
2437        }
2438
2439        // Output HTML
2440        return $field->render();
2441    }
2442
2443    /**
2444     * Check if the field is a file upload field.
2445     *
2446     * @param array $field The field to check.
2447     * @return bool True if the field is a file upload field, false otherwise.
2448     */
2449    public static function is_file_upload_field( $field ) {
2450        return ( is_array( $field ) &&
2451                ! empty( $field ) &&
2452                isset( $field['field_id'] ) &&
2453                isset( $field['files'] ) &&
2454                is_array( $field['files'] ) );
2455    }
2456
2457    /**
2458     * Get the default label from type.
2459     *
2460     * @param string $type - the type of label.
2461     *
2462     * @return string
2463     */
2464    public static function get_default_label_from_type( $type ) {
2465        switch ( $type ) {
2466            case 'text':
2467                $str = __( 'Text', 'jetpack-forms' );
2468                break;
2469            case 'name':
2470                $str = __( 'Name', 'jetpack-forms' );
2471                break;
2472            case 'number':
2473                $str = __( 'Number', 'jetpack-forms' );
2474                break;
2475            case 'email':
2476                $str = __( 'Email', 'jetpack-forms' );
2477                break;
2478            case 'url':
2479                $str = __( 'Website', 'jetpack-forms' );
2480                break;
2481            case 'date':
2482                $str = __( 'Date', 'jetpack-forms' );
2483                break;
2484            case 'telephone':
2485                $str = __( 'Phone', 'jetpack-forms' );
2486                break;
2487            case 'textarea':
2488                $str = __( 'Message', 'jetpack-forms' );
2489                break;
2490            case 'checkbox-multiple':
2491                $str = __( 'Choose several options', 'jetpack-forms' );
2492                break;
2493            case 'radio':
2494                $str = __( 'Choose one option', 'jetpack-forms' );
2495                break;
2496            case 'select':
2497                $str = __( 'Select one', 'jetpack-forms' );
2498                break;
2499            case 'consent':
2500                $str = __( 'Consent', 'jetpack-forms' );
2501                break;
2502            case 'file':
2503                $str = __( 'Upload a file', 'jetpack-forms' );
2504                break;
2505            case 'time':
2506                $str = __( 'Time', 'jetpack-forms' );
2507                break;
2508            case 'image-select':
2509                $str = __( 'Select an image', 'jetpack-forms' );
2510                break;
2511            default:
2512                $str = null;
2513        }
2514        return $str;
2515    }
2516
2517    /**
2518     * Loops through $this->fields to generate a (structured) list of field IDs.
2519     *
2520     * Important: Currently the allowed fields are defined as follows:
2521     *  `name`, `email`, `url`, `subject`, `textarea`
2522     *
2523     * If you need to add new fields to the Contact Form, please don't add them
2524     * to the allowed fields and leave them as extra fields.
2525     *
2526     * The reasoning behind this is that both the admin Feedback view and the CSV
2527     * export will not include any fields that are added to the list of
2528     * allowed fields without taking proper care to add them to all the
2529     * other places where they accessed/used/saved.
2530     *
2531     * The safest way to add new fields is to add them to the dropdown and the
2532     * HTML list ( @see Contact_Form_Field::render ) and don't add them
2533     * to the list of allowed fields. This way they will become a part of the
2534     * `extra fields` which are saved in the post meta and will be properly
2535     * handled by the admin Feedback view and the CSV Export without any extra
2536     * work.
2537     *
2538     * If there is need to add a field to the allowed fields, then please
2539     * take proper care to add logic to handle the field in the following places:
2540     *
2541     *  - Below in the switch statement - so the field is recognized as allowed.
2542     *
2543     *  - Contact_Form::process_submission - validation and logic.
2544     *
2545     *  - Contact_Form::process_submission - add the field as an additional
2546     *      field in the `post_content` when saving the feedback content.
2547     *
2548     *  - Contact_Form_Plugin::parse_fields_from_content - add mapping
2549     *      for the field, defined in the above method.
2550     *
2551     *  - Contact_Form_Plugin::map_parsed_field_contents_of_post_to_field_names -
2552     *      add mapping of the field for the CSV Export. Otherwise it will be missing
2553     *      from the exported data.
2554     *
2555     *  - admin.php / grunion_manage_post_columns - add the field to the render logic.
2556     *      Otherwise it will be missing from the admin Feedback view.
2557     *
2558     * @return array
2559     */
2560    public function get_field_ids() {
2561        $field_ids = array(
2562            'all'   => array(), // array of all field_ids.
2563            'extra' => array(), // array of all non-allowed field IDs.
2564
2565            // Allowed "standard" field IDs:
2566            // 'email'    => field_id,
2567            // 'name'     => field_id,
2568            // 'url'      => field_id,
2569            // 'subject'  => field_id,
2570            // 'textarea' => field_id,
2571        );
2572
2573        // Initialize marketing consent
2574        $field_ids['email_marketing_consent']       = null;
2575        $field_ids['email_marketing_consent_field'] = null;
2576
2577        foreach ( $this->fields as $id => $field ) {
2578            $type = $field->get_attribute( 'type' );
2579
2580            // If the field is not renderable, skip it.
2581            if ( ! $field->is_field_renderable( $type ) ) {
2582                continue;
2583            }
2584
2585            $field_ids['all'][] = $id;
2586
2587            if ( isset( $field_ids[ $type ] ) ) {
2588                // This type of field is already present in our allowed list of "standard" fields for this form
2589                // Put it in extra
2590                $field_ids['extra'][] = $id;
2591                continue;
2592            }
2593
2594            /**
2595             * See method description before modifying the switch cases.
2596             */
2597            switch ( $type ) {
2598                case 'email':
2599                case 'name':
2600                case 'url':
2601                case 'subject':
2602                case 'textarea':
2603                    $field_ids[ $type ] = $id;
2604                    break;
2605                case 'consent':
2606                    // Set email marketing consent for the first Consent type field
2607                    if ( null === $field_ids['email_marketing_consent'] ) {
2608                        $field_ids['email_marketing_consent_field'] = $id;
2609                        if ( $field->value ) {
2610                            $field_ids['email_marketing_consent'] = true;
2611                        } else {
2612                            $field_ids['email_marketing_consent'] = false;
2613                        }
2614                    }
2615                    $field_ids['extra'][] = $id;
2616                    break;
2617                default:
2618                    // Put everything else in extra
2619                    $field_ids['extra'][] = $id;
2620            }
2621        }
2622
2623        return $field_ids;
2624    }
2625
2626    /**
2627     * Process the contact form's POST submission
2628     * Stores feedback.  Sends email.
2629     */
2630    public function process_submission() {
2631
2632        $response = Feedback::from_submission( $_POST, $this ); // phpcs:Ignore WordPress.Security.NonceVerification.Missing
2633        $response->set_source( $this->get_source() );
2634
2635        // If the submission came from an authenticated form preview, flag the
2636        // feedback as a test submission. The rest of the pipeline reads the
2637        // flag from the feedback (which also travels into the serialized
2638        // post_content via Feedback_Source).
2639        if ( $this->is_preview_submission ) {
2640            $response->mark_as_test();
2641        }
2642        $is_test_submission = $response->is_test();
2643
2644        $plugin = Contact_Form_Plugin::init();
2645
2646        $id                  = $this->get_attribute( 'id' );
2647        $to                  = $this->get_attribute( 'to' );
2648        $widget              = $this->get_attribute( 'widget' );
2649        $block_template      = $this->get_attribute( 'block_template' );
2650        $block_template_part = $this->get_attribute( 'block_template_part' );
2651
2652        $contact_form_subject = $this->get_attribute( 'subject' );
2653
2654        $to     = str_replace( ' ', '', $to );
2655        $emails = explode( ',', $to );
2656
2657        $valid_emails = array();
2658
2659        foreach ( $emails as $email ) {
2660            if ( ! is_email( $email ) ) {
2661                continue;
2662            }
2663
2664            if ( function_exists( 'is_email_address_unsafe' ) && is_email_address_unsafe( $email ) ) {
2665                continue;
2666            }
2667
2668            $valid_emails[] = $email;
2669        }
2670
2671        // No one to send it to, which means none of the "to" attributes are valid emails.
2672        // Use default email instead.
2673        if ( ! $valid_emails ) {
2674            $valid_emails = $this->defaults['to'];
2675        }
2676
2677        $to = $valid_emails;
2678
2679        // Last ditch effort to set a recipient if somehow none have been set.
2680        if ( empty( $to ) ) {
2681            $to = get_option( 'admin_email' );
2682        }
2683
2684        if ( ! $this->has_verified_jwt ) {
2685            // Make sure we're processing the form we think we're processing... probably a redundant check.
2686            if ( $widget ) {
2687                if ( isset( $_POST['contact-form-id'] ) && 'widget-' . $widget !== $_POST['contact-form-id'] ) { // phpcs:Ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission()
2688                    return Form_Submission_Error::system_error( 'form_id_mismatch_widget', __( 'Form ID mismatch.', 'jetpack-forms' ) );
2689                }
2690            } elseif ( $block_template ) {
2691                if ( isset( $_POST['contact-form-id'] ) && 'block-template-' . $block_template !== $_POST['contact-form-id'] ) { // phpcs:Ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission()
2692                    return Form_Submission_Error::system_error( 'form_id_mismatch_block_template', __( 'Form ID mismatch.', 'jetpack-forms' ) );
2693                }
2694            } elseif ( $block_template_part ) {
2695                if ( isset( $_POST['contact-form-id'] ) && 'block-template-part-' . $block_template_part !== $_POST['contact-form-id'] ) { // phpcs:Ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission()
2696                        return Form_Submission_Error::system_error( 'form_id_mismatch_block_template_part', __( 'Form ID mismatch.', 'jetpack-forms' ) );
2697                }
2698            } elseif ( isset( $_POST['contact-form-id'] ) && ( empty( $this->current_post ) || self::get_post_property( $this->current_post, 'ID' ) !== (int) sanitize_text_field( wp_unslash( $_POST['contact-form-id'] ) ) ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Missing -- check done by caller process_form_submission()
2699                return Form_Submission_Error::system_error( 'form_id_mismatch_post', __( 'Form ID mismatch.', 'jetpack-forms' ) );
2700            }
2701        }
2702
2703        // Initialize all these "standard" fields to null
2704        $comment_author_email = $response->get_author_email();
2705        $comment_author       = $response->get_author();
2706
2707        $contact_form_subject = $response->get_subject();
2708
2709        // Set marketing consent
2710        $email_marketing_consent = $response->has_consent();
2711
2712        if ( null === $email_marketing_consent ) {
2713            $email_marketing_consent = false;
2714        }
2715
2716        $all_values   = $response->get_all_values( 'submit' );
2717        $extra_values = $response->get_legacy_extra_values( 'submit' );
2718
2719        if ( ! empty( $_REQUEST['is_block'] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- not changing the site.
2720            $extra_values['is_block'] = true;
2721        }
2722
2723        $contact_form_subject = trim( $contact_form_subject );
2724
2725        $comment_author_ip = Contact_Form_Plugin::get_ip_address();
2726
2727        // Ensure that Akismet gets all of the relevant information from the contact form,
2728        // not just the textarea field and predetermined subject.
2729        $akismet_vars = $response->get_akismet_vars();
2730
2731        $spam           = '';
2732        $akismet_values = $plugin->prepare_for_akismet( $akismet_vars );
2733
2734        // Is it spam? Test submissions (from form preview) skip Akismet entirely —
2735        // the form owner is explicitly running a test and we don't want Akismet
2736        // to learn from synthetic data or bounce the submission.
2737        if ( $is_test_submission ) {
2738            $is_spam = false;
2739        } else {
2740            /** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
2741            $is_spam = apply_filters( 'jetpack_contact_form_is_spam', false, $akismet_values );
2742        }
2743        if ( is_wp_error( $is_spam ) ) { // WP_Error to abort
2744            return $is_spam; // abort
2745        } elseif ( $is_spam === true ) {  // TRUE to flag a spam
2746            $spam = '***SPAM*** ';
2747        }
2748
2749        /**
2750         * Filter whether a submitted contact form is in the comment disallowed list.
2751         *
2752         * @module contact-form
2753         *
2754         * @since 8.9.0
2755         *
2756         * @param bool  $result         Is the submitted feedback in the disallowed list.
2757         * @param array $akismet_values Feedack values returned by the Akismet plugin.
2758         */
2759        $in_comment_disallowed_list = apply_filters( 'jetpack_contact_form_in_comment_disallowed_list', false, $akismet_values );
2760
2761        if ( ! $comment_author ) {
2762            $comment_author = $comment_author_email;
2763        }
2764
2765        /**
2766         * Filter the email where a submitted feedback is sent.
2767         *
2768         * @module contact-form
2769         *
2770         * @since 1.3.1
2771         *
2772         * @param string|array $to Array of valid email addresses, or single email address.
2773         * @param array $all_values Contact form fields
2774         */
2775        $to            = (array) apply_filters( 'contact_form_to', $to, $all_values );
2776        $reply_to_addr = $to[0]; // get just the address part before the name part is added
2777
2778        foreach ( $to as $to_key => $to_value ) {
2779            $to[ $to_key ] = Contact_Form_Plugin::strip_tags( $to_value );
2780            $to[ $to_key ] = self::add_name_to_address( $to_value );
2781        }
2782
2783        // Get the site domain and get rid of www.
2784        $sitename        = wp_parse_url( site_url(), PHP_URL_HOST );
2785        $from_email_addr = 'wordpress@';
2786
2787        if ( null !== $sitename ) {
2788            if ( str_starts_with( $sitename, 'www.' ) ) {
2789                $sitename = substr( $sitename, 4 );
2790            }
2791
2792            $from_email_addr .= $sitename;
2793        }
2794
2795        if ( ! empty( $comment_author_email ) ) {
2796            $reply_to_addr = $comment_author_email;
2797        }
2798
2799        /*
2800         * The email headers here are formatted in a format
2801         * that is the most likely to be accepted by wp_mail(),
2802         * without escaping.
2803         * More info: https://github.com/Automattic/jetpack/pull/19727
2804         */
2805        $headers = 'From: ' . $comment_author . ' <' . $from_email_addr . ">\r\n" .
2806            'Reply-To: ' . $comment_author . ' <' . $reply_to_addr . ">\r\n";
2807
2808        /**
2809         * Allow customizing the email headers.
2810         *
2811         * Warning: DO NOT add headers or header data from the form submission without proper
2812         * escaping and validation, or you're liable to allow abusers to use your site to send spam.
2813         *
2814         * Especially DO NOT take email addresses from the form data to add as CC or BCC headers
2815         * without strictly validating each address against a list of allowed addresses.
2816         *
2817         * @module contact-form
2818         *
2819         * @since 10.2.0
2820         *
2821         * @param string|array $headers        Email headers.
2822         * @param string       $comment_author Name of the author of the submitted feedback, if provided in form.
2823         * @param string       $reply_to_addr  Email of the author of the submitted feedback, if provided in form.
2824         * @param string|array $to             Array of valid email addresses, or single email address, where the form is sent.
2825         */
2826        $headers = apply_filters(
2827            'jetpack_contact_form_email_headers',
2828            $headers,
2829            $comment_author,
2830            $reply_to_addr,
2831            $to
2832        );
2833
2834        $all_values['email_marketing_consent'] = $email_marketing_consent;
2835
2836        $entry_values = $response->get_entry_values();
2837
2838        // Prefix the subject with [TEST] for test submissions so the form owner
2839        // can immediately tell this email came from a preview-mode submission.
2840        if ( $is_test_submission ) {
2841            /**
2842             * Filter the subject prefix applied to test (preview) feedback emails.
2843             *
2844             * @module contact-form
2845             *
2846             * @since 7.19.0
2847             *
2848             * @param string $prefix Default subject prefix for test submissions.
2849             */
2850            $test_prefix          = apply_filters( 'jetpack_forms_test_subject_prefix', '[TEST] ' );
2851            $contact_form_subject = $test_prefix . $contact_form_subject;
2852        }
2853
2854        /** This filter is already documented in \Automattic\Jetpack\Forms\ContactForm\Admin */
2855        $subject = apply_filters( 'contact_form_subject', $contact_form_subject, $all_values );
2856
2857        /*
2858         * Links to the feedback and the post.
2859         */
2860        if ( $block_template || $block_template_part || $widget ) {
2861            $url = home_url( '/' );
2862        } else {
2863            $url = self::get_permalink( $this->current_post ? self::get_post_property( $this->current_post, 'ID' ) : 0 );
2864        }
2865
2866        // translators: the time of the form submission.
2867        $date_time_format = _x( '%1$s \a\t %2$s', '{$date_format} \a\t {$time_format}', 'jetpack-forms' );
2868        $date_time_format = sprintf( $date_time_format, get_option( 'date_format' ), get_option( 'time_format' ) );
2869        $time             = wp_date( $date_time_format );
2870
2871        // Keep a copy of the feedback as a custom post type.
2872        if ( $in_comment_disallowed_list ) {
2873            $feedback_status = 'trash';
2874        } elseif ( $is_spam ) {
2875            $feedback_status = 'spam';
2876        } elseif ( 'no' === $this->get_attribute( 'saveResponses' ) ) {
2877            $feedback_status = 'jp-temp-feedback';
2878        } else {
2879            $feedback_status = 'publish';
2880        }
2881        $response->set_status( $feedback_status );
2882
2883        foreach ( (array) $akismet_values as $av_key => $av_value ) {
2884            $akismet_values[ $av_key ] = Contact_Form_Plugin::strip_tags( $av_value );
2885        }
2886
2887        foreach ( $all_values as $all_key => $all_value ) {
2888            $all_values[ $all_key ] = Contact_Form_Plugin::strip_tags( $all_value );
2889        }
2890
2891        foreach ( $extra_values as $ev_key => $ev_value ) {
2892            $extra_values[ $ev_key ] = Contact_Form_Plugin::strip_tags( $ev_value );
2893        }
2894
2895        /*
2896         * We need to make sure that the post author is always zero for contact
2897         * form submissions.  This prevents export/import from trying to create
2898         * new users based on form submissions from people who were logged in
2899         * at the time.
2900         *
2901         * Unfortunately wp_insert_post() tries very hard to make sure the post
2902         * author gets the currently logged in user id.  That is how we ended up
2903         * with this work around.
2904         */
2905        add_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10, 2 );
2906
2907        /**
2908         * Allows site owners to not include IP addresses in the saved form response.
2909         *
2910         * The IP address is still used as part of spam filtering, if enabled, but it is removed when this filter
2911         * is set to true before saving to the database and e-mailing the form recipients.
2912
2913         * @module contact-form
2914         *
2915         * @param bool $remove_ip_address Should the IP address be removed. Default false.
2916         * @param string $ip_address IP address of the form submission.
2917         *
2918         * @since 0.33.0
2919         */
2920        if ( apply_filters( 'jetpack_contact_form_forget_ip_address', false, $comment_author_ip ) ) {
2921            $comment_author_ip = null;
2922        }
2923
2924        $post_id       = 0;
2925        $feedback_post = $response->save();
2926        if ( $feedback_post instanceof WP_Post ) {
2927            $post_id = $feedback_post->ID;
2928        }
2929
2930        // once insert has finished we don't need this filter any more
2931        remove_filter( 'wp_insert_post_data', array( $plugin, 'insert_feedback_filter' ), 10 );
2932
2933        update_post_meta( $post_id, '_feedback_extra_fields', $this->addslashes_deep( $extra_values ) );
2934
2935        if ( 'publish' === $feedback_status ) {
2936            Contact_Form_Plugin::recalculate_unread_count();
2937        }
2938
2939        if ( defined( 'AKISMET_VERSION' ) ) {
2940            update_post_meta( $post_id, '_feedback_akismet_values', $this->addslashes_deep( $akismet_values ) );
2941        }
2942
2943        /**
2944         * Fires after the feedback post for the contact form submission has been inserted.
2945         *
2946         * @module contact-form
2947         *
2948         * @since 8.6.0
2949         *
2950         * @param integer $post_id The post id that contains the contact form data.
2951         * @param array   $this->fields An array containg the form's Contact_Form_Field objects.
2952         * @param boolean $is_spam Whether the form submission has been identified as spam.
2953         * @param array   $entry_values The feedback entry values.
2954         */
2955        do_action( 'grunion_after_feedback_post_inserted', $post_id, $this->fields, $is_spam, $entry_values );
2956
2957        // Build the complete email content via the renderer.
2958        $context_data = array(
2959            'time'                 => $time,
2960            'url'                  => $url,
2961            'comment_author'       => $comment_author,
2962            'comment_author_email' => $comment_author_email,
2963            'comment_author_ip'    => $comment_author_ip,
2964            'is_spam'              => $is_spam,
2965            'is_test'              => $is_test_submission,
2966            'feedback_status'      => $feedback_status,
2967        );
2968        $email        = Feedback_Email_Renderer::build_email_content( $post_id, $this, $response, $context_data );
2969        $message      = $email['message'];
2970
2971        // Always store the rendered email for the resend endpoint.
2972        update_post_meta( $post_id, '_feedback_email', $this->addslashes_deep( compact( 'to', 'message' ) ) );
2973
2974        /**
2975         * Filter to choose whether an email should be sent after each successful contact form submission.
2976         * This filter takes precedence over the emailNotifications attribute.
2977         *
2978         * @module contact-form
2979         *
2980         * @since 2.6.0
2981         *
2982         * @param bool|null $should_send Should an email be sent after a form submission.
2983         *                              - true: Send email regardless of emailNotifications setting
2984         *                              - false: Don't send email regardless of emailNotifications setting
2985         *                              - null: Use emailNotifications attribute to determine (default behavior)
2986         * @param int $post_id Post ID.
2987         */
2988        $should_send_email = apply_filters( 'grunion_should_send_email', null, $post_id );
2989
2990        // Determine if email should be sent based on filter precedence.
2991        if ( $should_send_email === true ) {
2992            // Filter explicitly says to send email
2993            $send_email = true;
2994        } elseif ( $should_send_email === false ) {
2995            // Filter explicitly says not to send email
2996            $send_email = false;
2997        } else {
2998            // Filter is null (default), use emailNotifications attribute
2999            $send_email = ( $this->get_attribute( 'emailNotifications' ) !== 'no' );
3000        }
3001
3002        // Test submissions always send the notification email (so the form
3003        // owner can verify their email flow end-to-end) regardless of the
3004        // emailNotifications attribute. Site admins who want to opt out can
3005        // return false from the filter below.
3006        if ( $is_test_submission ) {
3007            /**
3008             * Filter whether test (preview) submissions should trigger the notification email.
3009             *
3010             * @module contact-form
3011             *
3012             * @since 7.19.0
3013             *
3014             * @param bool     $send     Whether to send the test submission email. Default true.
3015             * @param int      $post_id  The feedback post ID.
3016             * @param Feedback $response The feedback response object.
3017             */
3018            $send_email = apply_filters( 'jetpack_forms_send_test_feedback_email', true, $post_id, $response );
3019        }
3020
3021        /**
3022         * Filter to determine if spam should still be emailed.
3023         *
3024         * @module contact-form
3025         */
3026        $send_even_if_spam = apply_filters( 'grunion_still_email_spam', false );
3027
3028        // Only fire send-related side effects when we are actually going to send.
3029        $will_send = ( $is_spam !== true && $send_email ) || ( true === $is_spam && $send_even_if_spam );
3030
3031        if ( $will_send ) {
3032            /**
3033             * Fires right before the contact form message is sent via email to
3034             * the recipient specified in the contact form.
3035             *
3036             * @module contact-form
3037             *
3038             * @since 1.3.1
3039             *
3040             * @param integer $post_id Post contact form lives on
3041             * @param array $all_values Contact form fields
3042             * @param array $extra_values Contact form fields not included in $all_values
3043             */
3044            do_action( 'grunion_pre_message_sent', $post_id, $all_values, $extra_values );
3045
3046            self::wp_mail( $to, "{$spam}{$subject}", $message, $headers );
3047        }
3048
3049        // Schedule deletes of old spam feedbacks.
3050        if ( ! wp_next_scheduled( 'grunion_scheduled_delete' ) ) {
3051            wp_schedule_event( time() + 250, 'daily', 'grunion_scheduled_delete' );
3052        }
3053
3054        // Schedule deletes of old temp feedbacks.
3055        if ( ! wp_next_scheduled( 'grunion_scheduled_delete_temp' ) ) {
3056            wp_schedule_event( time() + 250, 'daily', 'grunion_scheduled_delete_temp' );
3057        }
3058
3059        /**
3060         * Fires an action hook right after the email(s) have been sent.
3061         *
3062         * @module contact-form
3063         *
3064         * @since 7.3.0
3065         *
3066         * @param int $post_id Post contact form lives on.
3067         * @param string|array $to Array of valid email addresses, or single email address.
3068         * @param string $subject Feedback email subject.
3069         * @param string $message Feedback email message.
3070         * @param string|array $headers Optional. Additional headers.
3071         * @param array $all_values Contact form fields.
3072         * @param array $extra_values Contact form fields not included in $all_values
3073         */
3074        do_action( 'grunion_after_message_sent', $post_id, $to, $subject, $message, $headers, $all_values, $extra_values );
3075
3076        $refresh_args = array(
3077            'contact-form-id'   => $id,
3078            'contact-form-sent' => $post_id,
3079            'contact-form-hash' => $this->hash,
3080            '_wpnonce'          => wp_create_nonce( "contact-form-sent-{$post_id}" ), // wp_nonce_url HTMLencodes :( .
3081        );
3082
3083        // If the request accepts JSON, return a JSON response instead of redirecting
3084        $accepts_json = isset( $_SERVER['HTTP_ACCEPT'] ) && false !== strpos( strtolower( sanitize_text_field( wp_unslash( $_SERVER['HTTP_ACCEPT'] ) ) ), 'application/json' );
3085
3086        if ( $this->is_response_without_reload_enabled && $accepts_json ) {
3087            $data = array();
3088            if ( $response instanceof Feedback ) {
3089                $data = $response->get_compiled_fields( 'ajax', 'collection' );
3090            }
3091            wp_send_json(
3092                array(
3093                    'success'     => true,
3094                    'data'        => $data,
3095                    'refreshArgs' => $refresh_args,
3096                ),
3097                null, // @phan-suppress-current-line PhanTypeMismatchArgumentProbablyReal -- It takes null, but its phpdoc only says int.
3098                JSON_UNESCAPED_SLASHES
3099            );
3100        }
3101
3102        if ( defined( 'DOING_AJAX' ) && DOING_AJAX ) {
3103            return self::success_message( $post_id, $this );
3104        }
3105
3106        $redirect = $this->get_redirect_url( $refresh_args, $id, $post_id );
3107
3108        // phpcs:ignore WordPress.Security.SafeRedirect.wp_redirect_wp_redirect -- We intentially allow external redirects here.
3109        wp_redirect( $redirect );
3110        exit( 0 );
3111    }
3112
3113    /**
3114     * Check if the contact form has a custom redirect.
3115     *
3116     * @return bool True if the contact form has a custom redirect, false otherwise.
3117     */
3118    public function has_custom_redirect() {
3119        $confirmation_type = $this->get_confirmation_type();
3120
3121        if ( ! empty( $this->get_attribute( 'customThankyouRedirect' ) ) && 'redirect' === $confirmation_type ) {
3122            return true;
3123        }
3124        /**
3125         * Filter to check if the contact form has a redirect filter.
3126         *
3127         * @module contact-form
3128         *
3129         * @since 1.9.0
3130         *
3131         * @param bool $has_redirect True if the contact form has a redirect filter, false otherwise.
3132         */
3133        return (bool) has_filter( 'grunion_contact_form_redirect_url' );
3134    }
3135
3136    /**
3137     * Get the URL where the reader is redirected after submitting a form.
3138     *
3139     * @param array $refresh_args The arguments to be added to the redirect URL.
3140     * @param int   $id           Contact Form ID.
3141     * @param int   $post_id      Post ID.
3142     *
3143     * @return string The redirect URL.
3144     */
3145    public function get_redirect_url( $refresh_args, $id, $post_id ) {
3146        $confirmation_type = $this->get_confirmation_type();
3147        $redirect          = '';
3148        $custom_redirect   = false;
3149
3150        if ( 'redirect' === $confirmation_type ) {
3151            $custom_redirect = true;
3152            $redirect        = esc_url_raw( $this->get_attribute( 'customThankyouRedirect' ) );
3153        }
3154
3155        if ( ! $redirect ) {
3156            $custom_redirect = false;
3157            $redirect        = wp_get_referer();
3158        }
3159
3160        if ( ! $redirect ) { // wp_get_referer() returns false if the referer is the same as the current page.
3161            $custom_redirect = false;
3162            $redirect        = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '';
3163        }
3164
3165        if ( ! $custom_redirect ) {
3166            $redirect = add_query_arg(
3167                urlencode_deep( $refresh_args ),
3168                $redirect
3169            );
3170        }
3171
3172        /**
3173         * Filter the URL where the reader is redirected after submitting a form.
3174         *
3175         * @module contact-form
3176         *
3177         * @since 1.9.0
3178         *
3179         * @param string $redirect Post submission URL.
3180         * @param int $id Contact Form ID.
3181         * @param int $post_id Post ID.
3182         */
3183        return apply_filters( 'grunion_contact_form_redirect_url', $redirect, $id, $post_id );
3184    }
3185
3186    /**
3187     * Get the permalink for the post ID that include the page query parameter if it was set.
3188     *
3189     * @param int $post_id The post ID.
3190     *
3191     * return string The permalink for the post ID.
3192     */
3193    public static function get_permalink( $post_id ) {
3194        $url  = get_permalink( $post_id );
3195        $page = isset( $_POST['page'] ) ? absint( wp_unslash( $_POST['page'] ) ) : null; // phpcs:Ignore WordPress.Security.NonceVerification.Missing
3196        if ( $page ) {
3197            return add_query_arg( 'page', $page, $url );
3198        }
3199        return $url;
3200    }
3201
3202    /**
3203     * Wrapper for wp_mail() that enables HTML messages with text alternatives
3204     *
3205     * @param string|array $to          Array or comma-separated list of email addresses to send message.
3206     * @param string       $subject     Email subject.
3207     * @param string       $message     Message contents.
3208     * @param string|array $headers     Optional. Additional headers.
3209     * @param string|array $attachments Optional. Files to attach.
3210     *
3211     * @return bool Whether the email contents were sent successfully.
3212     */
3213    public static function wp_mail( $to, $subject, $message, $headers = '', $attachments = array() ) {
3214        return Feedback_Email_Renderer::wp_mail( $to, $subject, $message, $headers, $attachments );
3215    }
3216
3217    /**
3218     * Add a display name part to an email address
3219     *
3220     * SpamAssassin doesn't like addresses in HTML messages that are missing display names (e.g., `foo@bar.org`
3221     * instead of `Foo Bar <foo@bar.org>`.
3222     *
3223     * @param string $address - the email address.
3224     *
3225     * @return string
3226     */
3227    public function add_name_to_address( $address ) {
3228        // If it's just the address, without a display name
3229        if ( is_email( $address ) ) {
3230            $address_parts = explode( '@', $address );
3231
3232            /*
3233             * The email address format here is formatted in a format
3234             * that is the most likely to be accepted by wp_mail(),
3235             * without escaping.
3236             * More info: https://github.com/Automattic/jetpack/pull/19727
3237             */
3238            $address = sprintf( '%s <%s>', $address_parts[0], $address );
3239        }
3240
3241        return $address;
3242    }
3243
3244    /**
3245     * Get the content type that should be assigned to outbound emails
3246     *
3247     * @return string
3248     */
3249    public static function get_mail_content_type() {
3250        return Feedback_Email_Renderer::get_mail_content_type();
3251    }
3252
3253    /**
3254     * Wrap a message body with the appropriate in HTML tags
3255     *
3256     * This helps to ensure correct parsing by clients, and also helps avoid triggering spam filtering rules
3257     *
3258     * @param string $title - title of the email.
3259     * @param string $body - the message body.
3260     * @param string $footer - the footer containing meta information.
3261     * @param string $actions - HTML for actions displayed in the email.
3262     * @param array  $respondent_info - Optional. Respondent information array with 'name', 'email', 'avatar'.
3263     * @param array  $metadata - Optional. Metadata array with 'date', 'source', 'source_url', 'device', 'ip', 'ip_flag'.
3264     *
3265     * @return string
3266     */
3267    public static function wrap_message_in_html_tags( $title, $body, $footer, $actions = '', $respondent_info = array(), $metadata = array() ) {
3268        return Feedback_Email_Renderer::wrap_message_in_html_tags( $title, $body, $footer, $actions, $respondent_info, $metadata );
3269    }
3270
3271    /**
3272     * Add a plain-text alternative part to an outbound email
3273     *
3274     * This makes the message more accessible to mail clients that aren't HTML-aware, and decreases the likelihood
3275     * that the message will be flagged as spam.
3276     *
3277     * @param PHPMailer $phpmailer - the phpmailer.
3278     */
3279    public static function add_plain_text_alternative( $phpmailer ) {
3280        Feedback_Email_Renderer::add_plain_text_alternative( $phpmailer );
3281    }
3282
3283    /**
3284     * Add deepslashes.
3285     *
3286     * @param array $value - the value.
3287     * @return array The value, with slashes added.
3288     */
3289    public function addslashes_deep( $value ) {
3290        if ( is_array( $value ) ) {
3291            return array_map( array( $this, 'addslashes_deep' ), $value );
3292        } elseif ( is_object( $value ) ) {
3293            $vars = get_object_vars( $value );
3294            foreach ( $vars as $key => $data ) {
3295                $value->{$key} = $this->addslashes_deep( $data );
3296            }
3297            return (array) $value;
3298        }
3299
3300        return addslashes( $value );
3301    }
3302
3303    /**
3304     * Get the block's classes.
3305     * This gathers both the alignment classes and the layout classes,
3306     * which go on the outermost div.
3307     *
3308     * @param array $attributes Block attributes.
3309     * @param array $extra_container_classes Extra container classes.
3310     * @return string The block's classes.
3311     */
3312    public static function get_block_container_classes( $attributes = array(), $extra_container_classes = array() ) {
3313        // using wp-block-jetpack-contact-form-container here
3314        // confuses the layout support process, making it place the CSS classes on the container
3315        // instead of the actual block.
3316        $classes = array( 'jetpack-contact-form-container' );
3317
3318        $classes = array_merge( $classes, $extra_container_classes );
3319
3320        if ( isset( $attributes['variationName'] ) && $attributes['variationName'] === 'multistep' ) {
3321            $classes[] = 'is-multistep';
3322        }
3323
3324        $classes[] = self::get_block_alignment_class( $attributes );
3325
3326        return implode( ' ', $classes );
3327    }
3328
3329    /**
3330     * Rough implementation of Gutenberg's align-attribute-to-css-class map.
3331     * Only allowin "wide" and "full" as "center", "left" and "right" don't
3332     * make much sense for the form.
3333     *
3334     * @param array $attributes Block attributes.
3335     * @return string The CSS alignment class: alignfull | alignwide.
3336     */
3337    public static function get_block_alignment_class( $attributes = array() ) {
3338        $align_to_class_map = array(
3339            'wide' => 'alignwide',
3340            'full' => 'alignfull',
3341        );
3342        if ( empty( $attributes['align'] ) || ! array_key_exists( $attributes['align'], $align_to_class_map ) ) {
3343            return '';
3344        }
3345        return $align_to_class_map[ $attributes['align'] ];
3346    }
3347
3348    /**
3349     * Process a file upload field.
3350     *
3351     * @param string $field_id The field ID.
3352     * @param object $field The field object.
3353     *
3354     * @return array A structured array with field_id and files array.
3355     */
3356    public function process_file_upload_field( $field_id, $field ) {
3357        $field_id = sanitize_key( $field_id );
3358
3359        $raw_data = array();
3360        // phpcs:ignore WordPress.Security.NonceVerification.Missing
3361        if ( isset( $_POST[ $field_id ] ) ) {
3362
3363            // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Missing
3364            $raw_post_data = wp_unslash( $_POST[ $field_id ] );
3365            if ( is_array( $raw_post_data ) ) {
3366                $raw_data = array_map( 'sanitize_text_field', $raw_post_data );
3367            }
3368        }
3369
3370        $file_data_array = is_array( $raw_data )
3371            ? array_map(
3372                function ( $json_str ) {
3373                    $decoded = json_decode( $json_str, true );
3374                    return array(
3375                        'file_id' => isset( $decoded['file_id'] ) ? sanitize_text_field( $decoded['file_id'] ) : '',
3376                        'name'    => isset( $decoded['name'] ) ? sanitize_text_field( $decoded['name'] ) : '',
3377                        'size'    => isset( $decoded['size'] ) ? absint( $decoded['size'] ) : 0,
3378                        'type'    => isset( $decoded['type'] ) ? sanitize_text_field( $decoded['type'] ) : '',
3379                    );
3380                },
3381                $raw_data
3382            ) : array();
3383
3384        if ( empty( $file_data_array ) ) {
3385            $field->add_error( __( 'Failed to upload file.', 'jetpack-forms' ) );
3386            return array(
3387                'field_id' => $field_id,
3388                'files'    => array(),
3389            );
3390        }
3391
3392        return array(
3393            'field_id' => $field_id,
3394            'files'    => $file_data_array,
3395        );
3396    }
3397
3398    /**
3399     * Ensures a value is formatted as a string, taking into account file upload fields.
3400     *
3401     * @param mixed $value The value to transform.
3402     * @return mixed The transformed value.
3403     */
3404    private static function maybe_transform_value( $value ) {
3405        if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'image-select' ) {
3406            return implode(
3407                ', ',
3408                array_map(
3409                    function ( $choice ) {
3410                        $value = $choice['perceived'];
3411
3412                        if ( $choice['showLabels'] && ! empty( $choice['label'] ) ) {
3413                            $value .= ' - ' . $choice['label'];
3414                        }
3415
3416                        return $value;
3417                    },
3418                    $value['choices']
3419                )
3420            );
3421        }
3422
3423        // For URL fields, extract the display text value (original user input without auto-added protocol).
3424        if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'url' ) {
3425            // Prefer displayValue (raw input) over url (which may have https:// prepended).
3426            return $value['displayValue'] ?? ( $value['url'] ?? '' );
3427        }
3428
3429        // For rating fields, return the displayValue (e.g., "3/5") for text fallback.
3430        if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'rating' ) {
3431            return $value['displayValue'] ?? '';
3432        }
3433
3434        // For file upload fields, we want to show the file name and size
3435        if ( is_array( $value ) && isset( $value['name'] ) && isset( $value['size'] ) ) {
3436            $file_name = $value['name'];
3437            $file_size = $value['size'];
3438            return empty( $file_size ) ? $file_name : $file_name . ' (' . $file_size . ')';
3439        }
3440
3441        return $value;
3442    }
3443
3444    /**
3445     * Helper method to get the images from an image select field.
3446     *
3447     * Returns an array of image choice objects, each containing:
3448     * - src: The image URL
3449     * - letterCode: The letter code (e.g., 'A', 'B', 'C')
3450     * - label: The choice label text (empty string if showLabels is false)
3451     *
3452     * @param array $value The value to get the images from.
3453     * @return array|null The images with metadata, or null if not an image-select field.
3454     */
3455    private static function get_images( $value ) {
3456        if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'image-select' ) {
3457            return array_map(
3458                function ( $choice ) {
3459                    $letter_code = $choice['perceived'] ?? '';
3460                    $label       = '';
3461
3462                    if ( ! empty( $choice['showLabels'] ) && ! empty( $choice['label'] ) ) {
3463                        $label = $choice['label'];
3464                    }
3465
3466                    return array(
3467                        'src'        => $choice['image']['src'] ?? '',
3468                        'letterCode' => $letter_code,
3469                        'label'      => $label,
3470                    );
3471                },
3472                $value['choices']
3473            );
3474        }
3475
3476        return null;
3477    }
3478
3479    /**
3480     * Get files from a file field value if present.
3481     *
3482     * @param mixed $value The field value.
3483     *
3484     * @return array|null Array of file data if this is a file field, null otherwise.
3485     */
3486    private static function get_files( $value ) {
3487        if ( is_array( $value ) && isset( $value['type'] ) && $value['type'] === 'file' && ! empty( $value['files'] ) ) {
3488            return array_map(
3489                function ( $file ) {
3490                    $preview_url = $file['previewUrl'] ?? null;
3491                    $icon_url    = $file['iconUrl'] ?? null;
3492                    $has_preview = ! empty( $preview_url ) || ! empty( $icon_url );
3493
3494                    return array(
3495                        'name'       => $file['name'] ?? __( 'Attached file', 'jetpack-forms' ),
3496                        'size'       => $file['size'] ?? '',
3497                        'url'        => $file['url'] ?? '',
3498                        // Preview URLs are captured from the DOM for AJAX submissions
3499                        'previewUrl' => $preview_url,
3500                        'iconUrl'    => $icon_url,
3501                        // Boolean flag for easier binding evaluation
3502                        'hasPreview' => $has_preview,
3503                    );
3504                },
3505                $value['files']
3506            );
3507        }
3508
3509        return null;
3510    }
3511
3512    /**
3513     * Helper method to format a raw label string for display, including kses sanitization.
3514     *
3515     * @param string|null $raw_label The raw label input.
3516     * @return string The formatted and kses'd label string, or an empty string if raw_label is empty.
3517     */
3518    public static function escape_and_sanitize_field_label( $raw_label ) {
3519        if ( empty( $raw_label ) ) {
3520            return ''; // kses the empty string
3521        }
3522        return wp_kses( (string) $raw_label, array() );
3523    }
3524
3525    /**
3526     * Enforce required block supports UIs for Classic themes.
3527     *
3528     * @param \WP_Theme_JSON_Data $theme_json_data Theme JSON data object.
3529     *
3530     * @return \WP_Theme_JSON_Data Updated theme JSON settings.
3531     */
3532    public static function add_theme_json_data_for_classic_themes( $theme_json_data ) {
3533        if ( wp_is_block_theme() ) {
3534            return $theme_json_data;
3535        }
3536
3537        $data = $theme_json_data->get_data();
3538
3539        if ( ! isset( $data['settings']['blocks'] ) ) {
3540            $data['settings']['blocks'] = array();
3541        }
3542
3543        $data['settings']['blocks']['jetpack/input'] = array(
3544            'color'      => array(
3545                'text'       => true,
3546                'background' => false,
3547            ),
3548            'border'     => array(
3549                'color'  => true,
3550                'radius' => true,
3551                'style'  => true,
3552                'width'  => true,
3553            ),
3554            'typography' => array(
3555                'fontFamily'     => true,
3556                'fontSize'       => true,
3557                'fontStyle'      => true,
3558                'fontWeight'     => true,
3559                'letterSpacing'  => true,
3560                'lineHeight'     => true,
3561                'textDecoration' => true,
3562                'textTransform'  => true,
3563            ),
3564        );
3565
3566        // maybe need to add support for jetpack/phone-input
3567
3568        $data['settings']['blocks']['jetpack/options'] = array(
3569            'color'  => array(
3570                'text'       => true,
3571                'background' => true,
3572            ),
3573            'border' => array(
3574                'color'  => true,
3575                'radius' => true,
3576                'style'  => true,
3577                'width'  => true,
3578            ),
3579        );
3580
3581        $shared_settings                              = array(
3582            'color'      => array(
3583                'text'       => true,
3584                'background' => false,
3585            ),
3586            'typography' => array(
3587                'fontFamily'     => true,
3588                'fontSize'       => true,
3589                'fontStyle'      => true,
3590                'fontWeight'     => true,
3591                'letterSpacing'  => true,
3592                'lineHeight'     => true,
3593                'textDecoration' => true,
3594                'textTransform'  => true,
3595            ),
3596        );
3597        $data['settings']['blocks']['jetpack/label']  = $shared_settings;
3598        $data['settings']['blocks']['jetpack/option'] = $shared_settings;
3599
3600        $theme_json_class = get_class( $theme_json_data );
3601        return new $theme_json_class( $data, 'default' );
3602    }
3603
3604    /**
3605     * Validate the contact form fields.
3606     *
3607     * This method checks each field for errors and ensures that at least one field has a value.
3608     * If no fields have values and there are no errors, it adds an error indicating that the form is empty.
3609     */
3610    public function validate() {
3611        $has_value = false;
3612        // Validate the form fields before processing the form.
3613        foreach ( $this->fields as $field ) {
3614            $field->validate();
3615            if ( ! $has_value && $field->has_value() ) {
3616                $has_value = true;
3617            }
3618        }
3619
3620        if ( ! $has_value && ! $this->has_errors() ) {
3621            $this->add_error( 'empty', __( 'Please fill out at least one field.', 'jetpack-forms' ) );
3622        }
3623
3624        $ref_id = $this->get_attribute( 'ref' );
3625        if ( ! empty( $ref_id ) ) {
3626            $this->validate_ref( $ref_id );
3627        }
3628    }
3629
3630    /**
3631     * Validate the form reference.
3632     *
3633     * @param int $ref The form reference ID.
3634     */
3635    public function validate_ref( $ref ) {
3636        $form_post = get_post( $ref );
3637        if ( ! $form_post || self::POST_TYPE !== $form_post->post_type ) {
3638            $this->add_error( 'invalid_ref', __( 'Invalid form reference.', 'jetpack-forms' ) );
3639            return;
3640        }
3641        if ( $form_post->post_status !== 'publish' ) {
3642            $this->add_error( 'unpublished_form', __( 'Invalid form reference.', 'jetpack-forms' ) );
3643            return;
3644        }
3645    }
3646
3647    /**
3648     * Reset the static errors for the contact form.
3649     *
3650     * @param string $id The ID of the contact form to reset errors for. If null, resets all static errors.
3651     *
3652     * This method is used to clear the static errors stored in the class.
3653     */
3654    public static function reset_errors( $id = null ) {
3655        if ( $id && isset( self::$static_errors[ $id ] ) ) {
3656            unset( self::$static_errors[ $id ] );
3657            return;
3658        }
3659        self::$static_errors = array();
3660    }
3661
3662    /**
3663     * Add an error to the contact form.
3664     *
3665     * @param string $error_code    The error code.
3666     * @param string $error_message The error message.
3667     */
3668    public function add_error( $error_code, $error_message ) {
3669        $id = $this->get_attribute( 'id' );
3670        if ( ! isset( self::$static_errors[ $id ] ) ) {
3671            self::$static_errors[ $id ] = Form_Submission_Error::validation_error( $error_code, $error_message );
3672        } else {
3673            // If we already have errors, add this error to the existing Form_Submission_Error
3674            self::$static_errors[ $id ]->add( $error_code, $error_message );
3675        }
3676        $this->errors = self::$static_errors[ $id ];
3677    }
3678    /**
3679     * Check if the contact form has errors.
3680     *
3681     * @return bool True if the contact form has errors, false otherwise.
3682     */
3683    public function has_errors() {
3684        $id = $this->get_attribute( 'id' );
3685        if ( ! isset( self::$static_errors[ $id ] ) ) {
3686            return false;
3687        }
3688        return is_wp_error( self::$static_errors[ $id ] ) && ! empty( self::$static_errors[ $id ]->get_error_codes() );
3689    }
3690
3691    /**
3692     * Get the error messages of the contact form.
3693     *
3694     * @return array The errors of the contact form.
3695     */
3696    public function get_error_messages() {
3697        if ( ! $this->has_errors() ) {
3698            return array();
3699        }
3700        $id = $this->get_attribute( 'id' );
3701        return self::$static_errors[ $id ]->get_error_messages();
3702    }
3703
3704    /**
3705     * Get the confirmation type of the contact form from the deprecated customThankyou attribute.
3706     *
3707     * @return string The confirmation type of the contact form.
3708     */
3709    public function get_confirmation_type() {
3710        // Backward compat: customThankyou 'redirect' takes precedence for old forms
3711        if ( 'redirect' === $this->get_attribute( 'customThankyou' ) ) {
3712            return 'redirect';
3713        }
3714
3715        return $this->get_attribute( 'confirmationType' );
3716    }
3717
3718    /**
3719     * Get the disable summary of the contact form from the deprecated customThankyou attribute.
3720     *
3721     * @return string The disable summary of the contact form.
3722     */
3723    public function get_disable_summary() {
3724        $disable_summary = $this->get_attribute( 'disableSummary' );
3725        $custom_thankyou = $this->get_attribute( 'customThankyou' );
3726
3727        if ( '' === $disable_summary ) {
3728            $disable_summary = 'noSummary' === $custom_thankyou || 'message' === $custom_thankyou;
3729        }
3730
3731        return $disable_summary;
3732    }
3733}