validation -> duplicate check -> CAPTCHA * -> content filter -> store -> mail. */ final class Submission_Handler { public const QUERY_SENT = 'tflexforms_sent'; /** Automatic retries of a failed notification, after the first attempt. */ public const MAIL_RETRIES = 3; /** * Mailer error from the last resend_notification() call. * * @var string */ private static string $resend_error = ''; /** * Results of POSTs handled during this request, keyed by form ID, so the * renderer can show errors and refill values. * * @var array */ private static array $results = array(); /** * Processes a submission. * * @param Form $form Published form. * @param array $input Raw request data, already unslashed. * @param array> $files Uploaded files ($_FILES shape). */ public static function process( Form $form, array $input, array $files = array() ): Submission_Result { if ( ! empty( $form->settings['logged_in_only'] ) && ! is_user_logged_in() ) { return new Submission_Result( Submission_Result::LOGIN_REQUIRED, $form->message( 'login_required' ) ); } $blocked = Spam_Guard::check( $form, $input, Spam_Guard::client_ip() ); if ( null !== $blocked ) { $key = Submission_Result::RATE_LIMITED === $blocked ? 'rate_limited' : 'spam'; return new Submission_Result( $blocked, $form->message( $key ) ); } $validator = new Validator( $form ); if ( ! $validator->validate( $input, $files ) ) { return new Submission_Result( Submission_Result::VALIDATION_FAILED, $form->message( 'validation_error' ), $validator->values(), $validator->errors() ); } $values = $validator->values(); $ip = Spam_Guard::client_ip(); // A repeat of a submission already accepted (double click, resent POST) // gets the first result back. Checked before the CAPTCHA, because the // repeat carries the same single-use token. $claim = Submission_Keys::hash( $form, $input, $values ); if ( null !== $claim && ! Submission_Keys::claim( $claim, $form->id ) ) { return Submission_Keys::replay( $claim, $form ); } // CAPTCHA tokens are single-use, so they are checked only after the // visitor's input is valid; otherwise every typo would burn a token. if ( ! Captcha::passes( $form, $input, $ip ) ) { if ( null !== $claim ) { Submission_Keys::release( $claim ); } return new Submission_Result( Submission_Result::CAPTCHA_FAILED, $form->message( 'captcha_failed' ), $values ); } $result = self::accept( $form, $values, $validator, self::context( $input, $ip ), Content_Filter::is_spam( $form, $values, $ip ) ); if ( null !== $claim ) { // Only final outcomes are replayed; after a mail failure the visitor may retry. if ( Submission_Result::MAIL_FAILED === $result->status ) { Submission_Keys::release( $claim ); } else { Submission_Keys::complete( $claim, $result ); } } return $result; } /** * Stores and mails a valid submission. The entry is saved before any mail * is sent, so a mail failure never loses it; suspected spam is saved with * the spam status and not mailed. * * @param Form $form Form. * @param array $values Sanitized values. * @param Validator $validator Validator that accepted them. * @param array $context Request context. * @param bool $spam Whether the content filter flagged it. */ private static function accept( Form $form, array $values, Validator $validator, array $context, bool $spam ): Submission_Result { $demo = ! empty( $form->settings['demo_mode'] ); $store = ! $demo && ! empty( $form->settings['store_entries'] ); if ( $spam && ! $store ) { return new Submission_Result( Submission_Result::SPAM, $form->message( 'spam' ) ); } if ( ! $spam ) { /** * Fires after validation, before the entry is stored and mail is sent. * * @param Form $form Form. * @param array $values Sanitized values. */ do_action( 'tflexforms_before_send_mail', $form, $values ); } // Uploads are moved out of PHP's temp folder only now that everything passed. $stored = Uploads::store_temporarily( $validator->uploads() ); $entry_id = 0; if ( $store ) { $entry_id = self::store( $form, $values, $validator->inactive_fields(), $context, $spam ? Entry_Store::SPAM : 'unread' ); } $mailer = new Mailer( $form, $values, $context, array_column( $stored, 'path' ) ); if ( $spam ) { // Kept so "Not spam" can still deliver it later. if ( $entry_id > 0 ) { Entry_Store::set_mail_result( $entry_id, false, $mailer->notification() ); } $sent = false; } else { // Demo mode: everything runs except the actual email and storage. $sent = $demo || $mailer->send(); if ( $entry_id > 0 ) { Entry_Store::set_mail_result( $entry_id, $sent, $mailer->last_message() ); Entry_Store::log_mail_attempt( $entry_id, $sent, $mailer->last_error(), 'submit' ); if ( ! $sent && null !== $mailer->last_message() ) { // The mail server may only be down for a moment: try again in the background. Jobs::enqueue( 'retry_mail', array( 'entry_id' => $entry_id ), Jobs::backoff( 1 ), 1 + self::MAIL_RETRIES, 1 ); } } } if ( $entry_id > 0 && array() !== $stored ) { Entry_Store::add_files( $entry_id, Uploads::keep_for_entry( $stored, $entry_id ) ); } else { Uploads::discard( $stored ); } if ( $spam ) { /** * Fires when a submission flagged as spam is kept for review. * * @param Form $form Form. * @param array $values Sanitized values. * @param int $entry_id Stored entry ID or 0. */ do_action( 'tflexforms_spam_stored', $form, $values, $entry_id ); return new Submission_Result( Submission_Result::SPAM, $form->message( 'spam' ) ); } // The visitor's message is safe once it is stored, whatever the mail server says. $result = $sent || $entry_id > 0 ? new Submission_Result( Submission_Result::SENT, $form->message( 'success' ) ) : new Submission_Result( Submission_Result::MAIL_FAILED, $form->message( 'mail_failed' ), $values ); /** * Fires after a valid submission has been processed (sent or failed). * $entry_id is 0 when the submission was not stored. * * @param Form $form Form. * @param array $values Sanitized values. * @param Submission_Result $result Outcome. * @param int $entry_id Stored entry ID or 0. */ do_action( 'tflexforms_submitted', $form, $values, $result, $entry_id ); return $result; } /** * Saves the submission as an entry, before any mail is sent. Files are * added once the mail (which attaches them) has gone. * * @param Form $form Form. * @param array $values Validated values. * @param string[] $inactive Fields hidden by conditions. * @param array $context Request context. * @param string $status unread|spam. * @return int Entry ID or 0. */ private static function store( Form $form, array $values, array $inactive, array $context, string $status ): int { return Entry_Store::create( $form, Entry_Store::display_values( $form, $values, $inactive ), array( 'status' => $status, 'mail_sent' => false, 'user_id' => get_current_user_id(), // IPs are personal data: stored only when the site owner opts in. 'ip' => Settings::get( 'store_ip' ) ? (string) $context['ip'] : '', 'page_url' => $context['post'] instanceof \WP_Post ? (string) get_permalink( $context['post'] ) : '', ) ); } /** * Resends the kept notification of an entry whose mail failed (or that was * released from spam), with the entry's files attached when the form * attaches files. * * Every attempt is recorded in the entry's mail log. * * @param int $entry_id Entry ID. * @param string $source resend (by a user) or retry (automatic). * @return bool|null Whether it was sent; null when there is nothing to resend. */ public static function resend_notification( int $entry_id, string $source = 'resend' ): ?bool { self::$resend_error = ''; $entry = Entry_Store::get( $entry_id ); $message = null !== $entry ? Entry_Store::pending_mail( $entry ) : null; $post = null !== $entry ? get_post( (int) $entry->form_id ) : null; if ( null === $message || ! $post instanceof \WP_Post || Form_Post_Type::POST_TYPE !== $post->post_type ) { return null; } $form = Form::from_post( $post ); $copies = ! empty( $form->mail['attach_files'] ) ? Uploads::copies_for_mail( (array) $entry->files ) : array(); $message['attachments'] = array_column( $copies, 'path' ); $mailer = new Mailer( $form, array() ); $sent = $mailer->deliver( $message ); Uploads::discard( $copies ); Entry_Store::set_mail_result( $entry_id, $sent, $message ); self::$resend_error = $mailer->last_error(); Entry_Store::log_mail_attempt( $entry_id, $sent, self::$resend_error, $source ); return $sent; } /** * Job handler: one automatic retry of an entry's failed notification. * Done when it was sent, or when there is nothing left to send (resent by * hand, entry deleted or marked as spam). * * @param array $args Job arguments: entry_id. * @return bool|string True when done, otherwise the error. */ public static function retry_mail_job( array $args ): bool|string { $entry_id = (int) ( $args['entry_id'] ?? 0 ); $entry = Entry_Store::get( $entry_id ); if ( null === $entry || Entry_Store::SPAM === $entry->status ) { return true; } $sent = self::resend_notification( $entry_id, 'retry' ); if ( false !== $sent ) { return true; } return '' !== self::$resend_error ? self::$resend_error : __( 'The mail server did not accept the message.', 'flexsolutions-form-builder' ); } /** * Request context for mail placeholders. The page ID comes from a hidden * field, so it is only trusted to name a publicly viewable post. * * @param array $input Raw input. * @param string $ip Visitor IP. * @return array */ private static function context( array $input, string $ip ): array { $post_id = $input[ Spam_Guard::FIELD_POST_ID ] ?? ''; $post = is_string( $post_id ) && ctype_digit( $post_id ) ? get_post( (int) $post_id ) : null; if ( ! $post instanceof \WP_Post || 'publish' !== $post->post_status || ! is_post_type_viewable( $post->post_type ) || post_password_required( $post ) ) { $post = null; } return array( 'ip' => $ip, 'user_agent' => Content_Filter::user_agent(), 'post' => $post, ); } /** * Handles a classic (non-JavaScript) form POST. Hooked to template_redirect. */ public static function handle_post_request(): void { $method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_key( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : ''; // Nonce is verified inside Spam_Guard::check() for logged-in users; see its docs for why guests are exempt. // phpcs:ignore WordPress.Security.NonceVerification.Missing if ( 'POST' !== $method || ! isset( $_POST[ Spam_Guard::FIELD_FORM_ID ] ) ) { return; } // phpcs:ignore WordPress.Security.NonceVerification.Missing, WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used when it is all digits (next line). $raw_id = wp_unslash( $_POST[ Spam_Guard::FIELD_FORM_ID ] ); $form = is_string( $raw_id ) && ctype_digit( $raw_id ) ? Form::find( (int) $raw_id ) : null; if ( null === $form ) { return; } // CSRF check before anything else is read. Guests have no nonce (it // would break on cached pages); see Spam_Guard::check(). $nonce = isset( $_POST[ Spam_Guard::FIELD_NONCE ] ) && is_string( $_POST[ Spam_Guard::FIELD_NONCE ] ) ? sanitize_text_field( wp_unslash( $_POST[ Spam_Guard::FIELD_NONCE ] ) ) : ''; if ( is_user_logged_in() && ! wp_verify_nonce( $nonce, Spam_Guard::nonce_action( $form->id ) ) ) { self::$results[ $form->id ] = new Submission_Result( Submission_Result::SPAM, $form->message( 'spam' ) ); return; } // Every value is sanitized as plain text here; Validator then sanitizes // and checks each field for its type, and ignores unknown keys. // phpcs:ignore WordPress.Security.NonceVerification.Missing -- Verified above for logged-in visitors. $input = map_deep( wp_unslash( $_POST ), 'sanitize_textarea_field' ); // Files are checked field by field in Validator/Uploads (size, type, real content). $files = $_FILES; // phpcs:ignore WordPress.Security.NonceVerification.Missing $result = self::process( $form, is_array( $input ) ? $input : array(), $files ); if ( $result->is_success() ) { // Post/Redirect/Get: refreshing the thank-you page cannot resend the email. $redirect = self::redirect_url( $form ); if ( '' === $redirect ) { $request_uri = isset( $_SERVER['REQUEST_URI'] ) ? esc_url_raw( wp_unslash( $_SERVER['REQUEST_URI'] ) ) : '/'; $redirect = add_query_arg( self::QUERY_SENT, $form->id, remove_query_arg( self::QUERY_SENT, $request_uri ) ) . '#tflexforms-' . $form->id; } wp_safe_redirect( $redirect, 303 ); exit; } self::$results[ $form->id ] = $result; } /** * The form's "redirect after success" URL, re-validated as same-site. * * @param Form $form Form. */ public static function redirect_url( Form $form ): string { $url = (string) ( $form->settings['redirect_url'] ?? '' ); return '' === $url ? '' : (string) wp_validate_redirect( $url, '' ); } /** * Result to display for a form on this page load, if any. * * @param Form $form Form. */ public static function result_for( Form $form ): ?Submission_Result { if ( isset( self::$results[ $form->id ] ) ) { return self::$results[ $form->id ]; } // Display-only flag set by our redirect; it never triggers any action. // phpcs:ignore WordPress.Security.NonceVerification.Recommended if ( isset( $_GET[ self::QUERY_SENT ] ) && is_string( $_GET[ self::QUERY_SENT ] ) && sanitize_key( wp_unslash( $_GET[ self::QUERY_SENT ] ) ) === (string) $form->id ) { return new Submission_Result( Submission_Result::SENT, $form->message( 'success' ) ); } return null; } }