$post * @phpstan-param array $files * @return array{status: string, message: string, errors: array, values: array, redirect: string} * status is "sent", "invalid" (field errors) or "rejected" (spam signal, rate limit or unknown form). */ public function handle( int $form_id, array $post, int $now, string $ip, array $files = array() ): array { $form = $this->forms->find( $form_id ); if ( null === $form ) { return self::result( 'rejected', __( 'This form no longer exists.', 'forensix-digital' ) ); } $definition = $form['definition']; /** * Lets an add-on refuse submissions, for example while the form is closed. Return the * message for the visitor, or an empty string to accept the submission. * * @param string $message Message, empty by default. * @param int $form_id Form id. * @param FormDefinition $definition Definition. * @param array $post Submitted data. * @param int $now Unix time. */ $locked = (string) apply_filters( 'forensix_form_locked_submission', '', $form_id, $definition, $post, $now ); if ( '' !== $locked ) { return self::result( 'rejected', $locked ); } $verdict = SpamGuard::judge( $form_id, (string) ( $post[ SpamGuard::FIELD_HONEYPOT ] ?? '' ), (string) ( $post[ SpamGuard::FIELD_TOKEN ] ?? '' ), $now, self::secret() ); if ( SpamGuard::BOT === $verdict ) { // Bots get the normal answer, so they learn nothing; nothing is stored or sent. return self::result( 'sent', $definition->settings['confirmation']['message'], array(), array(), self::redirect( $definition ) ); } if ( SpamGuard::OK !== $verdict ) { return self::result( 'rejected', __( 'The form could not be checked. Please reload the page and try again.', 'forensix-digital' ) ); } if ( ! $this->allow_rate( $form_id, $ip ) ) { return self::result( 'rejected', __( 'Too many messages in a short time. Please wait a few minutes and try again.', 'forensix-digital' ) ); } if ( $definition->settings['captcha'] && Captcha::active() ) { $captcha = Captcha::verify( $post, new Http() ); if ( Captcha::UNREACHABLE === $captcha ) { return self::result( 'rejected', __( 'The spam check could not be reached. Please try again in a minute.', 'forensix-digital' ), array(), self::entered( $definition, $post ) ); } if ( Captcha::OK !== $captcha ) { return self::result( 'rejected', __( 'Please complete the spam check above the button and send the form again.', 'forensix-digital' ), array(), self::entered( $definition, $post ) ); } } $checked = $definition->validate( $post ); if ( array() === $checked['errors'] ) { $checked['errors'] = self::blocked( $definition, $checked['values'] ); } if ( array() === $checked['errors'] ) { /** * Lets an add-on check the submission further (uploads, calculations) and change the * values or add errors. Runs only when the normal checks found no errors. * * @param array $checked values and errors by field id. * @param FormDefinition $definition Definition. * @param array $post Submitted data. * @param array $files Uploaded files. */ $checked = self::shape( apply_filters( 'forensix_form_checked', $checked, $definition, $post, $files ), $checked ); } if ( array() !== $checked['errors'] ) { return self::result( 'invalid', __( 'Please correct the fields marked below.', 'forensix-digital' ), $checked['errors'], $checked['values'] ); } $entry_id = $this->entries->add( $form_id, $definition, $checked['values'] ); /** * Fires once the entry is saved, before the notification is sent. Add-ons that accept * uploads store the files here. * * @param int $entry_id Entry id. * @param int $form_id Form id. * @param FormDefinition $definition Definition. * @param array $values Clean values by field id. * @param array $files Uploaded files. */ do_action( 'forensix_form_entry_saved', $entry_id, $form_id, $definition, $checked['values'], $files ); $error = ''; $sent = $this->mail( $form['title'], $definition, $checked['values'], $error ); $this->entries->set_mail( $entry_id, $sent ? FormStore::MAIL_SENT : FormStore::MAIL_FAILED, $error ); if ( $sent && ! $definition->settings['store'] ) { $this->entries->delete( $entry_id ); } if ( ! $sent ) { /** * Fires when the notification of an entry could not be sent. The entry is kept. * * @param int $entry_id Entry id. * @param int $form_id Form id. * @param string $error Error message from the mailer. */ do_action( 'forensix_form_mail_failed', $entry_id, $form_id, $error ); } /** * Fires after the entry was saved and the notification attempted. The entry may already be * gone when the form keeps no entries. * * @param int $entry_id Entry id. * @param int $form_id Form id. * @param string $title Form title. * @param FormDefinition $definition Definition. * @param array $values Clean values by field id. * @param bool $sent Whether the notification was sent. */ do_action( 'forensix_form_submitted', $entry_id, $form_id, $form['title'], $definition, $checked['values'], $sent ); return self::result( 'sent', $definition->settings['confirmation']['message'], array(), array(), self::redirect( $definition ) ); } /** * Where the visitor goes after sending: the chosen address, a published page of this site, * or nowhere (the message is shown). * * @param FormDefinition $definition Definition. */ private static function redirect( FormDefinition $definition ): string { $confirmation = $definition->settings['confirmation']; if ( 'page' === $confirmation['type'] ) { $page_id = (int) $confirmation['page_id']; $link = 'publish' === get_post_status( $page_id ) ? get_permalink( $page_id ) : false; return is_string( $link ) ? $link : ''; } return $confirmation['url']; } /** * Field errors for email addresses on the form's block list. * * @param FormDefinition $definition Definition. * @param array $values Clean values by field id. * @return array */ private static function blocked( FormDefinition $definition, array $values ): array { $errors = array(); foreach ( $definition->fields as $field ) { if ( 'email' === $field['type'] && '' !== ( $values[ $field['id'] ] ?? '' ) && $definition->is_blocked( $values[ $field['id'] ] ) ) { $errors[ $field['id'] ] = __( 'This email address cannot be used here. Please use a different one.', 'forensix-digital' ); } } return $errors; } /** * What the visitor typed, so a rejected form can be shown again without losing it. * * @param FormDefinition $definition Definition. * @param array $post Submitted data. * @return array */ private static function entered( FormDefinition $definition, array $post ): array { return $definition->validate( $post )['values']; } /** * Takes the result of the forensix_form_checked filter if it has the right shape. * * @param mixed $candidate Filter result. * @param array $fallback Result before the filter. * @phpstan-param array{values: array, errors: array} $fallback * @return array{values: array, errors: array} */ private static function shape( mixed $candidate, array $fallback ): array { if ( ! is_array( $candidate ) || ! is_array( $candidate['values'] ?? null ) || ! is_array( $candidate['errors'] ?? null ) ) { return $fallback; } return array( 'values' => array_map( 'strval', $candidate['values'] ), 'errors' => array_map( 'strval', $candidate['errors'] ), ); } /** * Secret that signs the time token. */ public static function secret(): string { return wp_hash( 'forensix_form_token' ); } /** * Counts a submission and tells whether it is within the limit of RATE_LIMIT per RATE_WINDOW seconds. * * @param int $form_id Form id. * @param string $ip Visitor address. */ private function allow_rate( int $form_id, string $ip ): bool { $key = 'forensix_rl_' . $form_id . '_' . substr( wp_hash( $ip ), 0, 20 ); $count = (int) get_transient( $key ); if ( $count >= self::RATE_LIMIT ) { return false; } set_transient( $key, $count + 1, self::RATE_WINDOW ); return true; } /** * Sends the notification. * * @param string $title Form title. * @param FormDefinition $definition Definition. * @param array $values Clean values by field id. * @param string $error Set to the mailer's message when sending fails. * @phpstan-param array $values */ private function mail( string $title, FormDefinition $definition, array $values, string &$error ): bool { $to = '' !== $definition->settings['to'] ? $definition->settings['to'] : (string) get_option( 'admin_email' ); $subject = '' !== $definition->settings['subject'] ? $definition->settings['subject'] /* translators: %s: form title. */ : sprintf( __( 'New message: %s', 'forensix-digital' ), $title ); $lines = array(); $headers = array( 'Content-Type: text/plain; charset=UTF-8' ); $choice = $definition->settings['reply_to']; $reply_to = 'none' === $choice; foreach ( $definition->fields as $field ) { $value = $values[ $field['id'] ] ?? ''; if ( '' === $value ) { continue; } $lines[] = $field['label'] . ': ' . $value; // Answers go to the chosen email field, or to the first one when none is chosen. if ( 'email' === $field['type'] && ! $reply_to && ( '' === $choice || $choice === $field['id'] ) ) { $headers[] = 'Reply-To: ' . $value; $reply_to = true; } } $from = $definition->settings['from_email']; $from_name = null; if ( '' !== $from ) { // Quotes and line breaks are taken out of the name, so it cannot add headers. $name = trim( str_replace( array( '"', "\r", "\n" ), '', $definition->settings['from_name'] ) ); $headers[] = 'From: ' . ( '' !== $name ? '"' . $name . '" <' . $from . '>' : $from ); } elseif ( '' !== $definition->settings['from_name'] ) { $name = $definition->settings['from_name']; $from_name = static fn(): string => $name; add_filter( 'wp_mail_from_name', $from_name ); } if ( '' !== $definition->settings['cc'] ) { $headers[] = 'Cc: ' . $definition->settings['cc']; } $capture = static function ( \WP_Error $failure ) use ( &$error ): void { $error = $failure->get_error_message(); }; add_action( 'wp_mail_failed', $capture ); $sent = wp_mail( $to, wp_strip_all_tags( $subject ), implode( "\n\n", $lines ), $headers ); remove_action( 'wp_mail_failed', $capture ); if ( null !== $from_name ) { remove_filter( 'wp_mail_from_name', $from_name ); } if ( ! $sent && '' === $error ) { $error = __( 'WordPress could not send the email.', 'forensix-digital' ); } return $sent; } /** * Builds a result. * * @param string $status Status. * @param string $message Message for the visitor. * @param array $errors Field errors. * @param array $values Entered values. * @param string $redirect Address to go to, empty to show the message. * @phpstan-param array $errors * @phpstan-param array $values * @return array{status: string, message: string, errors: array, values: array, redirect: string} */ private static function result( string $status, string $message, array $errors = array(), array $values = array(), string $redirect = '' ): array { return array( 'status' => $status, 'message' => $message, 'errors' => $errors, 'values' => $values, 'redirect' => $redirect, ); } }