Jake Vanderwerf
2026-01-20 7a9054bb3f033c98067b3196378311dae54c5fbf
inc/rest/routes/FormRoutes.php
@@ -6,6 +6,7 @@
use JVBase\meta\MetaManager;
use JVBase\managers\CloudflareTurnstile;
use JVBase\blocks\FormBlock;
use JVBase\utility\Features;
use WP_REST_Request;
use WP_REST_Response;
use WP_Error;
@@ -32,15 +33,13 @@
      $this->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
    */
@@ -51,12 +50,12 @@
         [
            'methods' => 'POST',
            'callback' => [$this, 'submitForm'],
            'permission_callback' => '__return_true', // Public endpoint
            [
               'methods' => 'GET',
               'callback' => [$this, 'getForms'],
               'permission_callback' => [$this, 'checkPermission']
            ]
            'permission_callback' => [$this, 'checkRateLimit'], // Public endpoint, rate limited
         ],
         [
            'methods' => 'GET',
            'callback' => [$this, 'getForms'],
            'permission_callback' => [$this, 'checkPermission']
         ]
      ]);
@@ -95,10 +94,10 @@
         $form_type = $request->get_param('form_type');
         $form_id = $request->get_param('form_id');
         $form_data = $request->get_params();
         $files = $request->get_file_params();
         error_log('Form submission: '.print_r($request->get_params(), true));
         // Process the submission
         $result = $this->handleFormSubmission($form_type, $form_id, $form_data);
         $result = $this->handleFormSubmission($form_type, $form_id, $form_data, $files);
         if (is_wp_error($result)) {
            return new WP_REST_Response([
@@ -123,36 +122,65 @@
      }
   }
   protected function verifyTurnstile(string $token): bool
   {
      if (!Features::hasIntegration('cloudflare') || !JVB()->connect('cloudflare')->isSetUp()) {
         return true;
      }
      if (empty($token)) {
         return false;
      }
      return JVB()->connect('cloudflare')->verifyTurnstile($token);
   }
   /**
    * Handle the actual form submission logic
    */
   protected function handleFormSubmission(string $form_type, string $form_id, array $form_data): array|WP_Error
   protected function handleFormSubmission(string $form_type, string $form_id, array $form_data, array $files): array|WP_Error
   {
      // Get form configuration
      $form_config = FormBlock::getForm($form_type);
      error_log('Config: '.print_r($form_config, true));
      if (!$form_config) {
         return new WP_Error('invalid_form', 'Form configuration not found.');
      }
      // Verify Turnstile
      $turnstile_token = $form_data['cf-turnstile-response'] ?? '';
      // 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.');
//       }
//    }
      if (!$this->verifyTurnstile($turnstile_token)) {
         return new WP_Error('turnstile_failed', 'Security verification failed. Please try again.');
      }
      // Validate file uploads if present
      if (!empty($files)) {
         try {
            $files = $this->validateFileUploads($files, $form_config);
         } catch (\Exception $e) {
            return new WP_Error('file_validation_failed', 'File validation error: ' . $e->getMessage());
         }
      }
      // Validate and sanitize form data
      $processed_data = $this->validateAndSanitizeData($form_config, $form_data);
      error_log('Processed data: '.print_r($processed_data, true));
      try {
         $processed_data = $this->validateAndSanitizeData($form_config, $form_data);
      } catch (\Exception $e) {
         return new WP_Error('validation_failed', 'Data validation error: ' . $e->getMessage());
      }
      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);
      // Send email notification with attachments
      try {
         $email_sent = $this->sendEmailNotification($form_type, $form_config, $processed_data, $files);
      } catch (\Exception $e) {
         ;
         return new WP_Error('email_failed', 'Email error: ' . $e->getMessage());
      }
      if (!$email_sent) {
         return new WP_Error('email_failed', 'Failed to send your message. Please try again later.');
@@ -239,134 +267,310 @@
   /**
    * Send email notification
    */
   protected function sendEmailNotification(string $form_type, array $form_config, array $form_data): bool
   /**
    * Send email notification
    */
   protected function sendEmailNotification(string $form_type, array $form_config, array $form_data, array $files = []): 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'] ?? '';
      $submitter_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 = '<div style="font-family: -apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif; line-height: 1.6; color: #333;">';
      $body .= '<h2 style="color: #2563eb; border-bottom: 2px solid #e5e7eb; padding-bottom: 0.5rem;">New Form Submission: ' . esc_html($form_config['title']) . '</h2>';
      $body .= '<p style="color: #6b7280;"><strong>Submitted:</strong> ' . current_time('F j, Y \a\t g:i A') . '</p>';
      // Add note about email thread if submitter email exists
      if ($submitter_email) {
         $body .= '<div style="background: #f0f9ff; border-left: 4px solid #3b82f6; padding: 1rem; margin: 1rem 0; border-radius: 0 8px 8px 0;">';
         $body .= '<p style="margin: 0; color: #1e40af;"><strong>💬 Email Thread Created</strong></p>';
         $body .= '<p style="margin: 0.5rem 0 0 0; color: #1e40af; font-size: 0.9em;">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.</p>';
         $body .= '</div>';
      if (array_key_exists('first_name', $form_data)) {
         $submitter_name = $form_data['first_name'];
         $submitter_name .= array_key_exists('last_name', $form_data) ? ' '.$form_data['last_name'] : '';
      } elseif (array_key_exists('name', $form_data)) {
         $submitter_name = $form_data['name'];
      }
      $body .= '<div style="margin: 2rem 0;">';
      // 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 .= '<div style="margin-bottom: 1rem; padding: 0.75rem; background: #f9fafb; border-radius: 6px;">';
         $body .= '<strong style="color: #374151; display: block; margin-bottom: 0.25rem;">' . esc_html($label) . ':</strong>';
         $body .= '<span style="color: #6b7280;">' . nl2br(esc_html($value)) . '</span>';
         $body .= '</div>';
      }
      $body .= '</div>';
      // Add footer
      $body .= '<div style="border-top: 1px solid #e5e7eb; padding-top: 1rem; margin-top: 2rem;">';
      $body .= '<p style="color: #9ca3af; font-size: 0.8em; margin: 0;"><em>';
      $body .= 'This message was sent through the ' . esc_html($form_config['title']) . ' on ' . get_bloginfo('name');
      // Email headers
      $headers = [];
      if ($submitter_email) {
         $body .= '<br>To continue this conversation, simply reply to this email.';
      }
      $body .= '</em></p>';
      $body .= '</div>';
      $body .= '</div>';
      // 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';
         $cc_name = $submitter_name ?: '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 . '>';
         $headers[] = 'From: ' . $site_name . ' <' . $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[] = 'From: ' . $site_name . ' <' . $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);
      // Build email body
      $body = JVB()->email()->h2($form_config['title']);
      $body .= '<p style="font-size:13px;color:' . JVB()->email()->colours['dark-200'] . ';">
        Submitted: ' . current_time('F j, Y \a\t g:i A') . '
    </p>';
      // Send the unified email
      $email_sent = jvbMail($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})" : ""));
      // Add thread notice if CC'd
      if ($submitter_email) {
         $body .= JVB()->email()->notice(
            '<strong>Email Thread Created</strong><br>Reply to this email to continue the conversation with ' .
            esc_html($submitter_name ?: 'the submitter') . '.'
         );
      }
      return $email_sent;
      // Build form data array for table
      $form = [];
      foreach ($form_config['fields'] as $field_name => $config) {
         // Skip file upload fields
         if ($config['type'] == 'upload') {
            continue;
         }
         $value = $form_data[$field_name] ?? '';
         // Skip empty fields
         if (empty($value)) {
            continue;
         }
         $form[] = [
            'label' => $config['summaryTitle'] ?? $config['label'],
            'value' => $this->formatFieldValueForEmail($field_name, $value, $config)
         ];
      }
      $body .= JVB()->email()->spacer(20);
      $body .= JVB()->email()->table($form);
      // Show attachment info
      $attachments = $this->processFileAttachments($files);
      if (!empty($attachments)) {
         $body .= JVB()->email()->spacer(10);
         $body .= JVB()->email()->alert(
            sprintf('%d file(s) attached to this email', count($attachments)),
            'info'
         );
      }
      // Send the email
      return JVB()->email()->sendEmail(
         $to,
         $subject,
         $body,
         '',
         $headers,
         $attachments
      );
   }
   /**
    * Format field value for email display
    */
   protected function formatFieldValueForEmail(string $field_name, mixed $value, array $field_config): string
   {
      if (empty($value)) {
         return '';
      }
      $type = $field_config['type'] ?? 'text';
      $type = $field_config['subType'] ?? $type;
      switch ($type) {
         case 'phone':
         case 'tel':
            // Format phone number as xxx-xxx-xxxx
            $cleaned = preg_replace('/\D/', '', $value);
            if (strlen($cleaned) === 10) {
               return substr($cleaned, 0, 3) . '-' . substr($cleaned, 3, 3) . '-' . substr($cleaned, 6);
            }
            return $value;
         case 'checkbox':
         case 'set':
            // Convert array of values to comma-separated labels
            if (!is_array($value)) {
               $value = explode(',', $value);
            }
            $labels = [];
            foreach ($value as $val) {
               $val = trim($val);
               if (isset($field_config['options'][$val])) {
                  $labels[] = $field_config['options'][$val];
               } else {
                  $labels[] = $val; // Fallback to value if no label found
               }
            }
            return implode(', ', $labels);
         case 'radio':
         case 'select':
            // Return label instead of value
            if (isset($field_config['options'][$value])) {
               return $field_config['options'][$value];
            }
            return $value;
         case 'textarea':
         case 'wysiwyg':
            // Keep line breaks for email display
            return $value; // nl2br is already applied in the email body
         case 'true_false':
            $true_text = $field_config['true_text'] ?? 'Yes';
            $false_text = $field_config['false_text'] ?? 'No';
            return ($value === '1' || $value === 1 || $value === true) ? $true_text : $false_text;
         case 'email':
            return $value; // Will be clickable in email client
         case 'url':
            return $value; // Will be clickable in email client
         default:
            return is_array($value) ? implode(', ', $value) : $value;
      }
   }
   /**
    * Process uploaded files for email attachments
    * Files are in PHP's tmp directory and will be automatically cleaned up after request
    */
   protected function processFileAttachments(array $files): array
   {
      $attachments = [];
      if (empty($files)) {
         return $attachments;
      }
      foreach ($files as $field_name => $field_files) {
         // Skip metadata fields
         if (str_ends_with($field_name, '_meta')) {
            continue;
         }
         // Handle single file
         if (isset($field_files['tmp_name']) && !is_array($field_files['tmp_name'])) {
            if (!empty($field_files['tmp_name']) && is_uploaded_file($field_files['tmp_name'])) {
               $attachments[] = $field_files['tmp_name'];
            }
            continue;
         }
         // Handle multiple files (field[])
         if (is_array($field_files) && isset($field_files['tmp_name'])) {
            foreach ($field_files['tmp_name'] as $index => $tmp_name) {
               if (!empty($tmp_name) && is_uploaded_file($tmp_name)) {
                  $attachments[] = $tmp_name;
               }
            }
         }
      }
      return $attachments;
   }
   protected function validateFileUploads(array $files, array $form_config): array
   {
      $validated_files = [];
      $max_file_size = 10 * 1024 * 1024; // 10MB default
      $allowed_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'application/pdf'];
      foreach ($files as $field_name => $field_files) {
         // Skip metadata fields
         if (str_ends_with($field_name, '_meta')) {
            continue;
         }
         // Get field config for this upload field
         $field_config = $form_config['fields'][$field_name] ?? null;
         // Override defaults if field config specifies them
         if ($field_config) {
            $max_file_size = $field_config['max_file_size'] ?? $max_file_size;
            $allowed_types = $field_config['allowed_types'] ?? $allowed_types;
         }
         // Handle multiple files uploaded to same field (field[])
         if (isset($field_files['tmp_name']) && is_array($field_files['tmp_name'])) {
            $valid_indices = [];
            foreach ($field_files['tmp_name'] as $index => $tmp_name) {
               $file = [
                  'tmp_name' => $tmp_name,
                  'size' => $field_files['size'][$index] ?? 0,
                  'type' => $field_files['type'][$index] ?? '',
                  'name' => $field_files['name'][$index] ?? '',
                  'error' => $field_files['error'][$index] ?? UPLOAD_ERR_OK
               ];
               // Validate this file
               if ($this->isValidFile($file, $max_file_size, $allowed_types)) {
                  $valid_indices[] = $index;
               }
            }
            // Keep only valid files in the original structure
            if (!empty($valid_indices)) {
               $validated_files[$field_name] = [
                  'tmp_name' => [],
                  'size' => [],
                  'type' => [],
                  'name' => [],
                  'error' => []
               ];
               foreach ($valid_indices as $index) {
                  $validated_files[$field_name]['tmp_name'][] = $field_files['tmp_name'][$index];
                  $validated_files[$field_name]['size'][] = $field_files['size'][$index];
                  $validated_files[$field_name]['type'][] = $field_files['type'][$index];
                  $validated_files[$field_name]['name'][] = $field_files['name'][$index];
                  $validated_files[$field_name]['error'][] = $field_files['error'][$index];
               }
            }
         }
         // Handle single file
         elseif (isset($field_files['tmp_name']) && !is_array($field_files['tmp_name'])) {
            if ($this->isValidFile($field_files, $max_file_size, $allowed_types)) {
               $validated_files[$field_name] = $field_files;
            }
         }
      }
      return $validated_files;
   }
   /**
    * Validate a single file
    */
   protected function isValidFile(array $file, int $max_file_size, array $allowed_types): bool
   {
      // Check for upload errors
      if ($file['error'] !== UPLOAD_ERR_OK) {
         error_log("File upload error: " . $file['error'] . " for " . ($file['name'] ?? 'unknown'));
         return false;
      }
      // Check file size
      if ($file['size'] > $max_file_size) {
         error_log("File too large: " . $file['size'] . " bytes (max: $max_file_size) for " . ($file['name'] ?? 'unknown'));
         return false;
      }
      // Check file type
      if (!in_array($file['type'], $allowed_types)) {
         error_log("Invalid file type: " . $file['type'] . " for " . ($file['name'] ?? 'unknown'));
         return false;
      }
      // Security check - verify it's actually uploaded
      if (!is_uploaded_file($file['tmp_name'])) {
         error_log("Security check failed: file not uploaded via POST for " . ($file['name'] ?? 'unknown'));
         return false;
      }
      return true;
   }