action = 'form-'; $this->cache = CacheManager::for('forms', HOUR_IN_SECONDS); // Initialize Cloudflare Turnstile if available $this->turnstile = class_exists('JVBase\managers\CloudflareTurnstile') && jvbSiteUsesCloudflare() ? new CloudflareTurnstile() : null; // Add query vars add_filter('query_vars', [$this, 'addQueryVars']); } /** * Register REST routes */ public function registerRoutes(): void { // Form submission endpoint register_rest_route($this->namespace, '/forms', [ [ 'methods' => 'POST', 'callback' => [$this, 'submitForm'], 'permission_callback' => '__return_true', // Public endpoint [ 'methods' => 'GET', 'callback' => [$this, 'getForms'], 'permission_callback' => [$this, 'checkPermission'] ] ] ]); // Get specific form configuration register_rest_route($this->namespace, '/forms/(?P[a-zA-Z0-9_-]+)', [ [ 'methods' => 'GET', 'callback' => [$this, 'getForm'], 'permission_callback' => [$this, 'checkPermission'], 'args' => [ 'form_type' => [ 'required' => true, 'type' => 'string', 'sanitize_callback' => 'sanitize_text_field' ] ] ] ]); } /** * Add query vars for form handling */ public function addQueryVars(array $vars): array { $vars[] = 'jvb_submitted'; $vars[] = 'jvb_form_error'; return $vars; } /** * Handle form submission via REST API */ public function submitForm(WP_REST_Request $request): WP_REST_Response { try { $form_type = $request->get_param('form_type'); $form_id = $request->get_param('form_id'); $form_data = $request->get_params(); error_log('Form submission: '.print_r($request->get_params(), true)); // Process the submission $result = $this->handleFormSubmission($form_type, $form_id, $form_data); if (is_wp_error($result)) { return new WP_REST_Response([ 'success' => false, 'message' => $result->get_error_message() ]); } if (array_key_exists('success', $result)){ return new WP_REST_Response($result); } return new WP_REST_Response([ 'success' => true, 'data' => $result ], 200); } catch (Exception $e) { return new WP_REST_Response([ 'success' => false, 'message' => 'An error occurred while processing your submission.' ], 500); } } /** * Handle the actual form submission logic */ protected function handleFormSubmission(string $form_type, string $form_id, array $form_data): array|WP_Error { // Get form configuration $form_config = FormBlock::getForm($form_type); error_log('Config: '.print_r($form_config, true)); // Verify Turnstile if enabled //TODO: Reenable // if (jvbSiteUsesCloudflare() && $this->turnstile) { // error_log('Verifying turnstile...'); // $turnstile_token = $form_data['cf-turnstile-response'] ?? ''; // if (!$this->turnstile->verifyTurnstile($turnstile_token)) { // return new WP_Error('turnstile_failed', 'Security verification failed. Please try again.'); // } // } // Validate and sanitize form data $processed_data = $this->validateAndSanitizeData($form_config, $form_data); error_log('Processed data: '.print_r($processed_data, true)); if (array_key_exists('success', $processed_data) && $processed_data['success'] === false) { return $processed_data; } // Send email notification $email_sent = $this->sendEmailNotification($form_type, $form_config, $processed_data); if (!$email_sent) { return new WP_Error('email_failed', 'Failed to send your message. Please try again later.'); } // Store submission data temporarily for success display $this->cache->set('submission_' . $form_id, $processed_data); // Log successful submission $this->recordSubmission($_SERVER['REMOTE_ADDR'], $processed_data['email'] ?? ''); // Allow other plugins to hook into successful submission do_action('jvb_form_submitted', $form_type, $processed_data, $form_id); return $processed_data; } /** * Validate and sanitize form data */ protected function validateAndSanitizeData(array $form_config, array $form_data): array|WP_REST_Response { $meta = new MetaManager(null, 'form'); $processed_data = []; $errors = []; // Normalize form data to handle HTML checkbox arrays (field[] -> field) $normalized_form_data = []; foreach ($form_data as $key => $value) { // Remove [] suffix from checkbox field names $normalized_key = str_ends_with($key, '[]') ? substr($key, 0, -2) : $key; $normalized_form_data[$normalized_key] = $value; } foreach ($form_config['fields'] as $field_name => $field_config) { // Skip system fields if (in_array($field_name, ['action', 'form_id', 'form_type', 'timestamp', '_wpnonce', '_wp_http_referer', 'cf-turnstile-response'])) { continue; } $value = $normalized_form_data[$field_name] ?? ''; if (in_array($field_config['type'], ['checkbox', 'set'])) { $value = jvbCommaList((is_array($value)) ? $value : explode(',',$value)); } // Check required fields if (!empty($field_config['required']) && empty($value)) { $errors['required'][] = $field_name; continue; } // Skip empty optional fields if (empty($value) && empty($field_config['required'])) { continue; } // Add field name to config for error messages $field_config['name'] = $field_name; // Validate field if (!$meta->validator->validate($value, $field_config)) { $label = $field_config['label'] ?? ucfirst(str_replace('_', ' ', $field_name)); $errors['errors'][$field_name] = [ 'message' => sprintf('Field "%s" contains invalid data.', $label) ]; continue; } // Sanitize field $processed_data[$field_name] = $meta->sanitizer->sanitize($value, $field_config); } if (!empty($errors)) { $errors['success'] = false; return $errors; } return $processed_data; } /** * Send email notification */ protected function sendEmailNotification(string $form_type, array $form_config, array $form_data): bool { $admin_email = apply_filters('jvb_form_email_to', $form_config['email_to'], $form_type, $form_data); $subject = apply_filters('jvb_form_email_subject', $form_config['email_subject'], $form_type, $form_data); // Get submitter details $submitter_email = $form_data['email'] ?? null; $submitter_name = $form_data['name'] ?? ''; // Generate unique message ID for threading $form_id = $form_data['form_id'] ?? uniqid(); $timestamp = current_time('timestamp'); $message_id = sprintf('<%s-%s-%s@%s>', $form_type, $form_id, $timestamp, parse_url(home_url(), PHP_URL_HOST) ); // Build unified email body $body = '
'; $body .= '

New Form Submission: ' . esc_html($form_config['title']) . '

'; $body .= '

Submitted: ' . current_time('F j, Y \a\t g:i A') . '

'; // Add note about email thread if submitter email exists if ($submitter_email) { $body .= '
'; $body .= '

💬 Email Thread Created

'; $body .= '

This email includes both the admin and the person who submitted the form. Any replies will be shared between all parties, creating a conversation thread.

'; $body .= '
'; } $body .= '
'; // Add submission details in a nice format foreach ($form_config['fields'] as $field_name => $config) { $label = $config['summaryTitle'] ?? $config['label']; //Submitted Value $value = $form_data[$field_name]; if (str_contains($label, '%s')) { switch ($config['type']) { case 'true_false': $replace = ($value === '1') ? $config['isTrue'] : $config['isFalse']; break; } $label = sprintf( $label, $replace ); } $body .= '
'; $body .= '' . esc_html($label) . ':'; $body .= '' . nl2br(esc_html($value)) . ''; $body .= '
'; } $body .= '
'; // Add footer $body .= '
'; $body .= '

'; $body .= 'This message was sent through the ' . esc_html($form_config['title']) . ' on ' . get_bloginfo('name'); if ($submitter_email) { $body .= '
To continue this conversation, simply reply to this email.'; } $body .= '

'; $body .= '
'; $body .= '
'; // Prepare email headers for unified thread with enhanced threading $headers = [ 'Content-Type: text/html; charset=UTF-8', ]; // Set up recipients and reply-to based on whether submitter email exists if ($submitter_email) { // Primary recipient: admin $to = $admin_email; // Add submitter as CC so they both see the email $cc_name = $submitter_name ? $submitter_name : 'Form Submitter'; $headers[] = 'CC: ' . $cc_name . ' <' . $submitter_email . '>'; // Set reply-to to include both emails for unified thread $site_name = get_bloginfo('name'); $admin_name = get_bloginfo('name') . ' Team'; $headers[] = 'Reply-To: ' . $admin_name . ' <' . $admin_email . '>, ' . $cc_name . ' <' . $submitter_email . '>'; // Set from address to be more professional $headers[] = 'From: ' . $site_name . ' Forms <' . $admin_email . '>'; } else { // No submitter email, just send to admin $to = $admin_email; $site_name = get_bloginfo('name'); $headers[] = 'From: ' . $site_name . ' Forms <' . $admin_email . '>'; $headers[] = 'Reply-To: ' . $admin_email; } // Allow filtering of email data $email_data = apply_filters('jvb_form_unified_email_data', [ 'to' => $to, 'subject' => $subject, 'body' => $body, 'headers' => $headers, 'submitter_included' => !empty($submitter_email), 'submitter_email' => $submitter_email, 'message_id' => $message_id ], $form_type, $form_data); // Send the unified email $email_sent = JVB()->email()->sendEmail($email_data['to'], $email_data['subject'], $email_data['body'], implode(';',$email_data['headers'])); // Log the email sending for debugging if ($email_sent) { error_log("Form email sent successfully. Recipients: {$to}" . ($submitter_email ? " (CC: {$submitter_email})" : "") . " | Message-ID: {$message_id}"); } else { error_log("Failed to send form email to: {$to}" . ($submitter_email ? " (CC: {$submitter_email})" : "")); } return $email_sent; } /** * Record submission for rate limiting */ protected function recordSubmission(string $ip_address, string $email): void { // Record IP-based submissions $hourly_key = 'submissions_hour_' . md5($ip_address); $daily_key = 'submissions_day_' . md5($ip_address); $hourly_count = (int) get_transient($hourly_key) + 1; $daily_count = (int) get_transient($daily_key) + 1; set_transient($hourly_key, $hourly_count, HOUR_IN_SECONDS); set_transient($daily_key, $daily_count, DAY_IN_SECONDS); // Record email-based submissions if provided if (!empty($email)) { $email_key = 'submissions_email_' . md5($email); $email_count = (int) get_transient($email_key) + 1; set_transient($email_key, $email_count, DAY_IN_SECONDS); } } /** * Get all available forms */ public function getForms(WP_REST_Request $request): WP_REST_Response { $forms = FormBlock::getForms(); // Remove sensitive data $public_forms = []; foreach ($forms as $key => $config) { $public_forms[$key] = [ 'title' => $config['title'], 'description' => $config['description'], 'fields' => array_map(function($field) { // Remove sensitive configuration unset($field['email_to']); return $field; }, $config['fields']) ]; } return new WP_REST_Response($public_forms, 200); } /** * Get specific form configuration */ public function getForm(WP_REST_Request $request): WP_REST_Response { $form_type = $request->get_param('form_type'); $form_config = FormBlock::getForm($form_type); if (!$form_config) { return new WP_REST_Response([ 'error' => 'Form not found' ], 404); } // Remove sensitive data unset($form_config['email_to']); return new WP_REST_Response($form_config, 200); } }